File Uploads
reablocks doesn’t ship a dedicated uploader — file handling is a deep
rabbit hole (resumable, chunked, multi-source) and most apps already have
strong opinions about transports. Instead, this recipe shows two patterns
that pair the platform’s native <input type="file"> API with reablocks
components for the chrome:
- A simple click-to-upload button that posts to your endpoint.
- A drag-and-drop dropzone with previews and validation.
If you outgrow these — say you need resumable, chunked, or multi-provider uploads — reach for Uppy and keep the reablocks components for the surrounding UI.
Click-to-upload
The smallest useful pattern: a hidden <input> triggered by a reablocks
Button. The browser handles file selection; you handle the request.
import { useRef, useState } from 'react';
import { Button } from 'reablocks';
export const UploadButton = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [isUploading, setIsUploading] = useState(false);
const onFiles = async (files: FileList | null) => {
if (!files?.length) return;
setIsUploading(true);
try {
const body = new FormData();
Array.from(files).forEach(file => body.append('file', file));
await fetch('/api/upload', { method: 'POST', body });
} finally {
setIsUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
return (
<>
<Button
onClick={() => inputRef.current?.click()}
disabled={isUploading}
>
{isUploading ? 'Uploading…' : 'Choose file'}
</Button>
<input
ref={inputRef}
type="file"
className="hidden"
accept="image/*"
onChange={e => onFiles(e.target.files)}
/>
</>
);
};A few things worth knowing:
- Reset the input. Setting
inputRef.current.value = ''after upload ensures the user can pick the same file again and haveonChangefire. acceptis advisory. It filters the OS picker but doesn’t enforce anything — always validate the type/size on the client and the server.- Multi-file. Add
multipleto the input and the sameFormDataloop handles N files.
Drag-and-drop with previews
A more complete dropzone: drop or click to pick, validate size/type, show
a preview, and surface errors via reablocks’ Field. The platform
provides everything via the drag and drop API
— no library required.
import { useRef, useState } from 'react';
import { Button, Field, cn } from 'reablocks';
const MAX_BYTES = 5 * 1024 * 1024; // 5 MB
const ACCEPTED = ['image/png', 'image/jpeg', 'image/webp'];
type Preview = { file: File; url: string };
export const Dropzone = () => {
const inputRef = useRef<HTMLInputElement>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [error, setError] = useState<string | null>(null);
const [isDragging, setIsDragging] = useState(false);
const accept = (file: File) => {
if (!ACCEPTED.includes(file.type)) {
return 'Only PNG, JPEG, or WebP images are supported.';
}
if (file.size > MAX_BYTES) {
return 'File must be 5 MB or smaller.';
}
return null;
};
const handleFile = (file: File | undefined) => {
setError(null);
if (!file) return;
const err = accept(file);
if (err) {
setError(err);
return;
}
setPreview(prev => {
if (prev) URL.revokeObjectURL(prev.url);
return { file, url: URL.createObjectURL(file) };
});
};
return (
<Field label="Avatar" error={error ?? false}>
<div
onDragOver={e => {
e.preventDefault();
setIsDragging(true);
}}
onDragLeave={() => setIsDragging(false)}
onDrop={e => {
e.preventDefault();
setIsDragging(false);
handleFile(e.dataTransfer.files[0]);
}}
onClick={() => inputRef.current?.click()}
className={cn(
'border border-dashed rounded p-6 text-center cursor-pointer transition-colors',
isDragging
? 'border-primary bg-primary/5'
: 'border-panel-accent hover:border-primary'
)}
>
{preview ? (
<img
src={preview.url}
alt={preview.file.name}
className="mx-auto max-h-40 rounded"
/>
) : (
<p className="text-text-secondary">
Drag & drop, or click to choose. PNG / JPEG / WebP, up to 5 MB.
</p>
)}
<input
ref={inputRef}
type="file"
className="hidden"
accept={ACCEPTED.join(',')}
onChange={e => handleFile(e.target.files?.[0])}
/>
</div>
{preview && (
<div className="flex gap-2 mt-2">
<Button
variant="outline"
onClick={() => {
URL.revokeObjectURL(preview.url);
setPreview(null);
}}
>
Remove
</Button>
<Button onClick={() => upload(preview.file)}>Upload</Button>
</div>
)}
</Field>
);
};
async function upload(file: File) {
const body = new FormData();
body.append('file', file);
await fetch('/api/upload', { method: 'POST', body });
}Notes:
- Object URLs leak. Always
URL.revokeObjectURLwhen you replace or clear a preview, otherwise the browser keeps the file resident. Fielddoes the error UI for you. Pass the validation message straight toerror—Fieldrenders it withrole="alert"so screen readers announce it.cn()for the drag state. Thecnhelper merges the conditional border/background classes without conflict noise.
Tracking progress
fetch doesn’t expose upload progress, so for a real progress bar swap
to XMLHttpRequest (or use Uppy, which abstracts it). The simplest
percent-based hook:
import { useState } from 'react';
export const useUpload = () => {
const [progress, setProgress] = useState(0);
const upload = (file: File, url: string) =>
new Promise<void>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
};
xhr.onload = () =>
xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(xhr.status);
xhr.onerror = () => reject(xhr.status);
xhr.open('POST', url);
const body = new FormData();
body.append('file', file);
xhr.send(body);
});
return { progress, upload };
};Render it however you like — a thin filled bar inside a panel-colored track is usually enough:
<div className="h-1 bg-panel-accent rounded">
<div
className="h-1 bg-primary rounded transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>When to reach for Uppy
Switch to Uppy when you need any of:
- Resumable uploads (tus / S3 multipart) for large files.
- Pulling files from Google Drive, Dropbox, Box, Instagram, etc.
- A built-in queue with retry, pause, and per-file progress.
- Image cropping/resizing before upload.
Uppy is headless under the hood, so you can keep using reablocks for the
surrounding buttons, fields, and dialogs — wire Uppy’s events
(upload-progress, complete, error) into your local state and render
with reablocks components.
More
- Drag and Drop API — what the dropzone above is built on.
- Uppy — when native isn’t enough.
Field— the wrapper used above for labels and inline errors.