Independent reads and writes
Use a fetcher when an operation should keep the current page and URL.
Compatibility
load(url): independent reads
load selects the loader on a registered route. It changes this fetcher from idle to loading, then back to idle. The returned loader value goes to fetcher.data. It does not navigate or refresh the current page loader. A missing URL defaults to the bound route or current location.
import { useFetcher } from "@kasperrt/smolter/react";
import type { clientLoader } from "../tasks.$id/clientLoader";
export default function TaskPreview() {
const preview = useFetcher<typeof clientLoader>();
const busy = preview.state !== "idle";
return (
<section aria-busy={busy}>
<button onClick={() => preview.load("/tasks/42")} disabled={busy}>
Preview task
</button>
{preview.data && <p>{preview.data.title}</p>}
{preview.error && <p role="alert">{preview.error.message}</p>}
</section>
);
}GET Form: independent search
A fetcher GET form serializes fields into the target URL and loads that route. The browser address does not change. Render the returned search results from this fetcher, rather than useLoaderData. The target loader reads q from request.url.
const search = useFetcher<typeof clientLoader>();
return (
<section>
<search.Form action="/tasks/search" method="get">
<label>Query <input name="q" /></label>
<button>Find tasks</button>
</search.Form>
{search.data?.results.map((task) => <p key={task.id}>{task.title}</p>)}
</section>
);submit(body, options): mutations
Pass FormData, an optional action URL, and an uppercase method. The default method is POST. The action result goes to fetcher.data. Successful mutations revalidate the active page loader subject to shouldRevalidate. The returned Promise resolves to a Result with data or error; use fetcher.data for the hook’s inferred result type.
const body = new FormData();
body.set("title", "Updated task");
await fetcher.submit(body, { action: "/tasks/42", method: "PATCH" });
// DELETE uses the same registered route action.
await fetcher.submit(new FormData(), {
action: "/tasks/42",
method: "DELETE",
});Form: declarative mutations
The bound Form accepts the same props as Form, including action and lowercase method. It uses this fetcher’s state and data rather than page actionData. Standard named fields and the selected submit button become FormData.
<fetcher.Form action="/tasks/42" method="patch">
<label>
Title <input name="title" required />
</label>
<button disabled={fetcher.state !== "idle"}>Save</button>
</fetcher.Form>;state: pending UI
Reads: idle → loading → idle. Mutations: idle → submitting → loading → idle, with loading covering revalidation. Keep controls in place and render pending feedback separately.
data and error: ownership and lifetime
data is initially undefined and retains the previous result while another request is pending. A validation object is data, while an Error is exposed through error. An action result is not the same as the page’s freshly loaded data. There is no global keyed fetcher registry. Mounted fetchers that previously loaded data revalidate their last URL after a successful mutation, subject to shouldRevalidate; unmounted fetchers are disposed.
Types and dynamic URLs
useFetcher<typeof handler>() types data from one handler. useFetcher(typedRoute) infers both read and mutation results; narrow the union. Supply a concrete URL, such as /tasks/42, when the bound dynamic route is not active.
Concurrency and cleanup
A new request aborts the previous request on that fetcher and ignores late results. Separate fetchers run independently. Unmounting disposes the channel. Forward signal to fetch; cancellation cannot undo a completed server write.
Differences to keep in mind
This API does not expose React Router’s fetcher.formData, formAction, formMethod, keyed fetchers, or a reset method. Keep any optimistic draft in your own component state and reconcile it with the confirmed result. A redirect returned by an action navigates even though an ordinary fetcher submission stays on the current page.