---
title: React
description: One hook, every Files verb. Imperative methods with upload progress and ambient error state, plus optional reactive useList / useFile / useSearch.
---

`files-sdk/react` brings the full Files API to the browser as a React hook. `useFiles` returns one method per `Files` verb against the [gateway](/docs/ui/server/gateway) you mounted, plus ambient upload/error state. Keys are plain strings (relative to your [authorized prefix](/docs/ui/server/authorization)).

```tsx lineNumbers
const files = useFiles({
  endpoint: "/api/files", // default "/api/files"
  headers: () => ({ authorization: token }), // sent on every call (static or lazy)
});
```

## The verbs

```ts lineNumbers
// Reads
await files.head("docs/a.txt"); // → FileInfo (metadata, no body)
await files.exists("docs/a.txt"); // → boolean
await files.list({ prefix: "docs/" }); // → { items, prefixes?, cursor? }
await files.url("img.png"); // → string (for <img src>)
await files.capabilities(); // → AdapterCapabilities

// Writes
await files.upload(file); // → { key, size, contentType, etag?, data? }
await files.delete("old.txt");
await files.copy("a.txt", "b.txt");
await files.move("a.txt", "c.txt");

// Streaming iterators
for await (const f of files.listAll()) {
  /* every object */
}
for await (const f of files.search("docs/*.pdf")) {
  /* glob/regex */
}
```

`search()` returns the matches from one bounded gateway walk: the gateway reads at most `maxSearchScan` keys per request. The generator's return value, a `SearchSummary` (`{ truncated }`), says whether it stopped early; a `for await` loop drops it, so drive the iterator by hand when you need it:

```ts lineNumbers
const it = files.search("**/*.pdf");
let step = await it.next();
while (!step.done) {
  /* step.value is a FileInfo */
  step = await it.next();
}
if (step.value.truncated) {
  /* narrow the pattern or prefix */
}
```

Every verb mirrors the SDK's signature, including the bulk array forms — `files.delete([a, b])`, `files.head([a, b])`, `files.download([a, b])` — which return the same partial-result shape (`{ results, errors? }`) and never throw on a single bad key. With `stopOnError: true`, items run one at a time in order and the call resolves at the first failure with what finished plus that one error, as on the server; nothing keeps running after it resolves.

## Uploading

`upload` has three forms, resolved by argument shape:

```ts lineNumbers
// Keyless - the server mints the key (the common case).
const { key } = await files.upload(file);

// Explicit key - you choose it (subject to the authorized prefix).
await files.upload("avatars/me.png", file, { contentType: "image/png" });

// Bulk - pooled at the configured concurrency.
await files.upload([
  { key: "a.txt", body: "one" },
  { key: "b.txt", body: "two" },
]);
```

A keyed upload is stored with its `contentType`, else the body's own type: a `Blob`'s `type`, or `text/plain; charset=utf-8` for a string, as the SDK does. A `Content-Type` in the hook's `headers` never overrides it.

Track progress per call, or read the hook's ambient state:

```tsx lineNumbers
await files.upload(file, {
  onProgress: (p, perFile) => console.log(p.fraction, perFile[0]?.status),
});

// or read the ambient state this hook keeps across every upload() call:
files.isUploading; // true while any upload() from this hook is running
files.uploads; // FileUploadState[] - one entry per file
files.progress; // { loaded, total, fraction } over the current entries
```

`uploads` accumulates: every file from every `upload()` call — keyless, keyed, and each item of a bulk `upload([...])`, whether they run one after another or at the same time — gets one entry. An entry keeps its position for the whole upload and is replaced by a fresh snapshot each time it changes, so it re-renders like any other state. Its `status` moves from `"uploading"` to a terminal value on every path: `"success"`, `"error"` (with `error` set to the `FilesError`), or `"aborted"`. `onProgress` follows the same lifecycle and fires once more after the terminal status.

`files.reset()` clears the finished entries. Uploads that are still running keep their entries, and `isUploading` stays `true` until they settle.

When the server's gateway has an [`onUploadComplete`](/docs/ui/server/upload-lifecycle) hook, each upload result (and its `uploads` entry) carries what the hook returned as `data`. Type it from the router with a type-only import: `useFiles<InferUploadData<typeof router>>()`.

## Downloading vs linking

Two tools, two jobs:

```ts lineNumbers
// download(key) → StoredFile: fetched via your gateway (proxied, or redirected
// to a signed URL). Works on every adapter. Use it when you need the data
// programmatically.
const file = await files.download("report.pdf");
const blob = await file.blob(); // lazy - only fetched on access
const text = await file.text();
const chunk = await files.download("video.mp4", {
  range: { start: 0, end: 1023 },
});

// url(key) → string: a direct link. Use it for <img src> / <a href> / <video>.
// Only meaningful when the adapter can sign (capabilities.signedUrl.supported).
const src = await files.url("avatar.png");
```

The returned `StoredFile` is the same lazy, `File`-shaped value the [SDK returns](/docs/api/stored-file) — `blob()`/`text()`/`arrayBuffer()`/`stream()` plus `key`, `size`, `contentType`, `etag`, `metadata`.

A ranged `download` must come back as `206 Partial Content`. If the gateway (or the storage it redirects to) ignores the range and sends the whole object, the call rejects rather than handing you the full body as if it were the slice. A `416` (a range the object can't satisfy, or a range on an adapter without range reads) rejects with an `Invalid` error.

## Errors & cancellation

A thrown error is a `FilesError` (`.code` of `"NotFound" | "Unauthorized" | "Conflict" | "ReadOnly" | "Invalid" | "Unsupported" | "Provider"`), rebuilt from the wire envelope. A response that doesn't carry the envelope, such as a `401` from auth middleware in front of the gateway or a storage host's error, is classified by its status (`401`/`403` → `Unauthorized`, `404` → `NotFound`, `409`/`412` → `Conflict`, otherwise `Provider`), with the body's reason in the message. A network failure is a `Provider` error with the original error as its `cause`, and a cancelled call has `aborted: true`. The last error from any verb is also mirrored to `files.error` for render-time display. That includes errors thrown while you iterate `listAll()` or `search()`:

```tsx lineNumbers
try {
  await files.delete(key);
} catch (e) {
  // e instanceof FilesError
}

// …and in the component's render:
return files.error ? (
  <p>
    {files.error.code}: {files.error.message}
  </p>
) : null;
```

The hook owns an `AbortController` merged into every call. `files.abort()` cancels everything in flight (their `uploads` entries end `"aborted"`); unmounting does the same automatically. `files.reset()` clears the ambient error and the finished upload entries, and re-arms after an abort. Pass a per-call `signal` (or a hook-level `signal` option) to cancel selectively.

## Reactive reads

For a file browser you usually want declarative state, not imperative calls. Three optional hooks wrap the read verbs with `data` / `error` / `isLoading` / `isFetching` / `refetch`, aborting their in-flight request on dependency change or unmount:

```tsx lineNumbers
import { useList, useFile, useSearch } from "files-sdk/react";

const { data, isLoading, refetch } = useList({ prefix: "docs/" });
const file = useFile(selectedKey); // head() for a preview; disabled without a key
const hits = useSearch(query, { match: "substring" });
```

`data` always belongs to the current input. When the key, options, or endpoint change, the hook drops the old result and reports `isLoading` until the new one arrives, and a disabled query (`useFile(undefined)`, or `enabled: false`) has no `data` or `error`. A `refetch()` keeps the current `data` while it reloads (`isFetching` without `isLoading`), and keeps it if the reload fails. A `signal` in the hook's options cancels its request too, which settles the query with an `aborted` error.

They are deliberately cache-free and dependency-light. For shared caching, bring React Query or SWR and call a `useFiles()` method inside your `queryFn` — the imperative methods are designed for exactly that.

## Outside React

The hook is a thin shell over `createFilesClient` from `files-sdk/client` — the same verb set, framework-agnostic, usable from Node, a worker, or a Vue/Svelte binding:

```ts lineNumbers
import { createFilesClient } from "files-sdk/client";

const client = createFilesClient({
  endpoint: "https://app.example.com/api/files",
});

const file = await client.download("report.pdf");
```
