A React file upload is about twenty lines of input type="file" and fetch, and those twenty lines are genuinely correct. They stay correct until the first 2 GB file arrives on a hotel connection, because fetch has no upload progress event and no way to retry the part that failed. Every example below runs from a repo you can clone, on Vite, Next.js and Remix.
The code lives at filestack-snippets, and it runs on React 19.2 and React 18.3.1 alike.
This is the clone-and-run companion rather than a from-scratch build. If you would rather type it out step by step in an empty app, the React file upload tutorial does that. What follows assumes you want a working repo in front of you first.
Key takeaways
- Twenty lines of
FormDataandfetchis a complete upload, and correct until files get large. fetchhas no upload progress event, so a spinner is your only feedback without a library.- The
acceptattribute filters the dialog only; real validation happens on your server. - Read
filesFailedas well asfilesUploaded, or a partial failure looks like success. - Gate the picker behind state, because rendering the component is what opens it.
What you need before you start
Node 18 or newer, and a React app. If you already have one, skip ahead.
npm create vite@latest my-upload-app -- --template react-ts
cd my-upload-app
npm install
For the Filestack half of this article you also need a free API key, and two packages:
npm install filestack-react@^7.0.1 filestack-js@^4.0.1
The second install is not optional. v7 moved filestack-js from a bundled dependency to a peer dependency, so it goes in your own package.json now. The upside is that you ship one copy of it rather than two, and you get the v4 client methods.
The API key belongs in the browser. It identifies your application rather than authenticating it, so it is not a secret. The app secret is a different value, it signs policies, and it never leaves your server.
The plain implementation, complete and runnable
No library. A file input, a piece of state, and fetch. This is the version worth understanding before you reach for anything else.
import { useState } from 'react';
export default function PlainUpload() {
const [file, setFile] = useState<File | null>(null);
const [status, setStatus] = useState('idle');
async function handleSubmit(event: React.FormEvent) {
event.preventDefault();
if (!file) return;
setStatus('uploading');
const body = new FormData();
body.append('file', file);
try {
const response = await fetch('/api/upload', { method: 'POST', body });
if (!response.ok) throw new Error(`server said ${response.status}`);
setStatus('done');
} catch (error) {
setStatus(error instanceof Error ? error.message : 'failed');
}
}
return (
<form onSubmit={handleSubmit}>
<input
type="file"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
<button type="submit" disabled={!file}>Upload</button>
<p>{status}</p>
</form>
);
}
Two details in that code. Do not set a Content-Type header when the body is FormData, because the browser has to write the multipart boundary itself. And read event.target.files?.[0], since the list is empty rather than null when someone opens the dialog and cancels.
That is the fetch version in full. It works, and on a fast connection with small files it will keep working for a while. The step-by-step version of this same build, with axios and a progress bar, is the tutorial linked at the top.
Handling multiple files
Add the multiple attribute and read the whole list.
<input type="file" multiple onChange={(e) => setFiles(Array.from(e.target.files ?? []))} />
FileList is array-like but not an array, so Array.from is what makes .map work on it. The JavaScript side of this pattern is covered in the guide to uploading multiple files in JavaScript if you want the non-React version.
The parts that break in production
The code above is complete. Four things arrive shortly afterwards.
Progress reporting. fetch cannot report upload progress. There is no event for it. You either move to XMLHttpRequest, which still has upload.onprogress, or you accept a spinner. For a 2 GB file on a hotel connection, a spinner is not an answer.
Retries. One dropped packet at 90% and the whole transfer starts again. Real implementations chunk the file, upload the chunks in parallel, and retry only the chunks that failed.
Validation you can trust. The accept attribute filters the file dialog and nothing else. A renamed .exe still arrives. The MIME type in file.type comes from the client and is trivially forged, so the check has to happen on your server against the actual bytes.
Image handling. The file that lands is the file the phone produced, which is often a 12 MP HEIC in the wrong orientation. Resizing, rotating and converting it is a second project.
None of these are hard problems individually. Together they are four more projects sitting alongside the twenty lines that already work.
Doing the same job with Filestack
Same screen, same result, without the four projects. Configure once at the root:
// src/main.tsx
import { createRoot } from 'react-dom/client';
import { FilestackProvider } from 'filestack-react';
import App from './App';
createRoot(document.getElementById('root')!).render(
<FilestackProvider
apikey={import.meta.env.VITE_FILESTACK_API_KEY}
pickerOptions={{ accept: ['image/*', 'application/pdf'], maxFiles: 5 }}
onUploadDone={(result) => console.log(result.filesUploaded)}
>
<App />
</FilestackProvider>,
);
FilestackProvider is new in v7 and it is the reason the component below has no props. Every picker underneath reads the key, the options and the callbacks from context. A component that sets its own prop still wins, and option objects are shallow-merged with the provider’s as the base, so one screen can accept a different file type without touching the root.
Then the upload screen itself:
// src/examples/OverlayExample.tsx
import { useState } from 'react';
import { PickerOverlay } from 'filestack-react';
import type { PickerResponse } from 'filestack-react';
export default function OverlayExample() {
const [open, setOpen] = useState(false);
const [handles, setHandles] = useState<string[]>([]);
const handleDone = (result: PickerResponse) => {
setHandles(result.filesUploaded.map((file) => file.handle));
setOpen(false);
};
return (
<>
<button onClick={() => setOpen(true)}>Upload a file</button>
{open && <PickerOverlay onUploadDone={handleDone} />}
</>
);
}
Progress, chunking, retries, cloud sources and image editing are inside that component. The picker opens as soon as it renders, which is why it sits behind open rather than in the tree unconditionally.
Choosing between the three components
| Component | What it renders | Reach for it when |
|---|---|---|
PickerOverlay |
a modal above the page | uploading interrupts something else |
PickerInline |
a picker inside your layout | the upload is the screen |
PickerDropPane |
a drop target with no chrome | the form around it already explains itself |
PickerDropPane is the closest thing to a React dropzone file upload example, and it is the one to use when you have already drawn the box and only need files to land in it.
Types with no types package
The SDK was rewritten in TypeScript for v7 and the declarations ship inside the package. There is no @types/filestack-react to install, and any declare module shim you wrote for v6 can be deleted.
import type { PickerResponse, PickerFileMetadata, FilestackError } from 'filestack-react';
const onUploadDone = (result: PickerResponse): void => {
setFiles(result.filesUploaded);
};
const onError = (error: FilestackError | Error): void => {
setMessage(error.message);
};
PickerResponse splits into filesUploaded and filesFailed, and both need reading. A picker that uploaded four of five files calls onUploadDone once, with the fifth sitting in the second array. Reading only the first is the quietest way to lose a file.
Verifying it works
Each uploaded file comes back with a handle, and the handle is the only part worth storing. It addresses the file on the CDN, so no API key belongs in a delivery URL.
https://cdn.filestackcontent.com/HANDLE
Open that and you should see your file. Transformations are path segments in front of the handle, which is how you fix the 12 MP phone photo without a build step:
https://cdn.filestackcontent.com/resize=width:600/HANDLE
https://cdn.filestackcontent.com/output=format:webp/HANDLE
That is the same mechanism behind React image editing.
If nothing appears, check three things in order. A 403 usually means the key is wrong or the domain is not on the allow list. An empty filesUploaded array with a populated filesFailed means the upload was rejected rather than lost. And a picker that does not appear when the button is clicked is usually one rendered without a state gate, which opened and closed on mount.
The picker brings its own interface, but the button that opens it is yours. If your project is on Bootstrap, the button and input patterns match unchanged. For the input-level styling that the plain version needs, the Bootstrap file upload styling guide covers hiding the native control without breaking keyboard access.
For the picker itself, pass a single child element and it becomes the container:
<PickerInline onUploadDone={handleDone}>
<div style={{ height: 420, border: '1px solid #d0d0d0', borderRadius: 12 }} />
</PickerInline>
The component clones that element, sets the generated DOM id on it, and mounts the picker inside, so your border and radius survive.
The full working example
Rather than paste 400 lines, clone the repo:
git clone https://github.com/Fileschool/filestack-snippets
cd filestack-snippets/content/blogs/react-file-upload-example-you-can-clone
npm install
cp .env.example .env # add your key
npm run dev
| Path | What it shows |
|---|---|
src/main.tsx |
the provider, configured once |
src/examples/OverlayExample.tsx |
the modal picker behind a button |
src/examples/InlineExample.tsx |
the in-page picker |
src/examples/DropPaneExample.tsx |
the drop target |
src/examples/TypedCallbacks.tsx |
strict TypeScript on the result |
src/examples/FolderUpload.tsx |
a whole directory at once |
src/examples/SecurePicker.tsx |
a picker scoped by a signed policy |
server/policy-server.mjs |
signing, with the secret server side |
examples/nextjs-app-router/ |
the App Router client boundary |
examples/remix/ |
Remix and the server render |
The two framework folders install separately because they need their own toolchains. Both build clean: the Next.js page prerenders static at 226 KB first load, and Remix splits the picker into its own chunk while keeping the server bundle at 7 KB.
The picker is a large dependency. The Vite production build here is 786 KB, 197 KB gzipped, most of it the picker. Loading it behind an interaction rather than at the top of the route keeps it off first paint, and rendering the overlay behind useState already does that.
Uploading a whole folder
Directory selection is a filestack-js v4 capability, which v7 can reach precisely because it stopped pinning its own copy of that package. It is one picker option, and what changes is the shape of the result rather than the code around it.
<PickerOverlay
pickerOptions={{
fromSources: ['local_file_system'],
maxFiles: 100,
allowManualRetry: true,
}}
onUploadDone={(result: PickerResponse) => setHandles(result.filesUploaded.map((f) => f.handle))}
/>
allowManualRetry matters more here than anywhere else. A hundred-file directory on a home connection will drop something, and without it the only recovery is to start the whole selection again.
Questions people ask about this
Does React have a built-in file upload component?
There is none. React gives you <input type="file" /> and the browser File API, and everything above that is yours to write or install.
Can you upload a file in React without a library?
Yes, and the first example on this page is the whole of it. FormData plus fetch is a complete upload. What you are choosing when you add a library is not the upload itself but progress reporting, chunked retries, server-side type checking and image handling.
Should the upload go through your own server or straight to storage?
Straight to storage is faster and cheaper, because the file never occupies a request thread on your application. The trade is that your server no longer sees the bytes, so any validation you were doing there moves to a signed policy that constrains what the client is allowed to send in the first place.
Why does the picker open immediately?
Because rendering the component is what opens it. There is no open prop. Gate it behind state, and close it in onUploadDone by setting that state back to false.
Where should the API key live?
In the client, in an environment variable, and it can safely appear in your bundle. It names the application rather than proving who you are. The value that must stay on the server is the app secret, which signs policies.
How large a file can this handle?
The picker chunks uploads and retries the failed chunk rather than the whole transfer, so connection stability matters more than size. Per-file size caps come from your plan and from any maxSize you set in pickerOptions or in a signed policy. The plain fetch version has no chunking, so a single failed request restarts the transfer from the beginning.
Where to go next
If you are wiring this into a real product, the next questions are the general ones rather than the React ones: what the upload should accept, where it should store, and what it should do to the file on the way. Every prop that controls those is listed on the React file upload SDK page.
One scope note before you copy any of this into a mobile project. The picker is a DOM component, so a React native file upload example is a separate build against the Filestack REST API rather than a variation on the code above.
Joshua is a web developer with over 4 years of experience building responsive, high-performance websites and web applications. Currently working as an AI Automation Specialist, he combines modern web development with automation to create efficient, scalable digital solutions. He shares practical insights on WordPress, web development, and emerging technologies.
Read More →