smolter
API

useFetcher

Own an independent route request channel.

Compatibility

ReactAvailable in this renderer's browser integration.
React SSRAvailable to this renderer's SSR integration; browser event handlers run after hydration.
PreactAvailable in this renderer's browser integration.
Preact SSRAvailable to this renderer's SSR integration; browser event handlers run after hydration.

Import from @kasperrt/smolter/react @kasperrt/smolter/preact

Signature

function useFetcher<T extends RouteModule>(
  route: RouteDefinition<T>,
): TypedFetcher<
  | DataOf<Extract<T["action"] | T["clientAction"], AnyDataFunction>>
  | DataOf<Extract<T["loader"] | T["clientLoader"], AnyDataFunction>>
>;

function useFetcher<T = DataFunction>(): TypedFetcher<RouteData<T>>;

Parameters

Optionally pass a typed route definition to bind the default target and infer loader/action results. Otherwise provide a handler type generic.

Returns

data, error, state, Form, load(url?), and submit(formData, { action?, method? }).

Usage

Import this API from one of the entry points listed above. Keep server and build helpers in their dedicated subpaths.

const fetcher = useFetcher<typeof loader>();

<button onClick={() => fetcher.load("/tasks/1")}>Load task</button>
<p>{fetcher.data?.title}</p>

Behavior and edge cases

load reads a registered loader into fetcher.data. submit defaults to POST and does not navigate unless the action redirects. A bound dynamic pattern uses the concrete active URL only when that route is active; otherwise provide a concrete URL. Unmounting aborts the fetcher. Reads do not revalidate the page; successful writes do.

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/loader";

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>
  );
}

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. A subsequent registered revalidator can still reject the submission Promise. The fetcher leaves submitting state before that failure is propagated, including after a redirect, and remains usable for later requests. Handle that rejection without repeating a successful mutation.

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

idle means this channel has no work pending. loading means a read is pending, or page data is being revalidated after an action. submitting means the mutation is pending. A successful write normally moves submitting → loading → idle. Keep controls stable and show a separate status message. Each useFetcher call has independent state.

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. Successful mutations revalidate registered fetchers that have completed a read, subject to their route’s shouldRevalidate; submitting fetchers and disposed channels are skipped.

Types and dynamic URLs

useFetcher<typeof handler>() derives its data from a specific loader or action. useFetcher(typedRoute) derives a union of the route’s read and mutation results. Narrow that union before reading operation-specific fields. For a bound /tasks/:id route that is not active, pass a concrete URL such as /tasks/42; the router cannot invent id.

Concurrency and cleanup

Starting another request on the same fetcher aborts its previous request and ignores late completion. Separate fetchers can run concurrently. The framework hook disposes its channel when unmounted. Forward signal from the handler to fetch so cancellation reaches the network. Cancellation does not roll back a write already processed by a backend.

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.

View implementation ↗