Fetchers are useful for creating complex, dynamic user interfaces that require multiple, concurrent data interactions without causing a navigation.
Fetchers track their own, independent state and can be used to load data, mutate data, submit forms, and generally interact with loaders and actions.
With the introduction of form actions, transitions, useOptimistic
, useActionState
, and useFormStatus
, React has extended its scope into the same use cases as fetchers. You may want to consider using React's built-in APIs before reaching for a fetcher in React Router.
If you are on React 18, fetchers are still a great way to manage complex data interactions.
The most common case for a fetcher is to submit data to an action, triggering a revalidation of route data. Consider the following route module:
import { useLoaderData } from "react-router";
export async function clientLoader({ request }) {
let title = localStorage.getItem("title") || "No Title";
return { title };
}
export default function Component() {
let data = useLoaderData();
return (
<div>
<h1>{data.title}</h1>
</div>
);
}
First we'll add an action to the route for the fetcher to call:
import { useLoaderData } from "react-router";
export async function clientLoader({ request }) {
// ...
}
export async function clientAction({ request }) {
await new Promise((res) => setTimeout(res, 1000));
let data = await request.formData();
localStorage.setItem("title", data.get("title"));
return { ok: true };
}
export default function Component() {
let data = useLoaderData();
// ...
}
Next create a fetcher and render a form with it:
import { useLoaderData, useFetcher } from "react-router";
// ...
export default function Component() {
let data = useLoaderData();
let fetcher = useFetcher();
return (
<div>
<h1>{data.title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
</fetcher.Form>
</div>
);
}
If you submit the form now, the fetcher call the action and revalidate the route data automatically.
Fetchers make their state available during the async work so you can render pending UI the moment the user interacts:
export default function Component() {
let data = useLoaderData();
let fetcher = useFetcher();
return (
<div>
<h1>{data.title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
</div>
);
}
Sometimes there's enough information in the form to render the next state immediately. You can access the form data with fetcher.formData
:
export default function Component() {
let data = useLoaderData();
let fetcher = useFetcher();
let title = fetcher.formData?.get("title") || data.title;
return (
<div>
<h1>{title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
</fetcher.Form>
</div>
);
}
Data returned from an action is available in the fetcher's data
property. This is primarily useful for returning error messages to the user for a failed mutation:
// ...
export async function clientAction({ request }) {
await new Promise((res) => setTimeout(res, 1000));
let data = await request.formData();
let title = data.get("title") as string;
if (title.trim() === "") {
return { ok: false, error: "Title cannot be empty" };
}
localStorage.setItem("title", title);
return { ok: true, error: null };
}
export default function Component() {
let data = useLoaderData();
let fetcher = useFetcher();
let title = fetcher.formData?.get("title") || data.title;
return (
<div>
<h1>{title}</h1>
<fetcher.Form method="post">
<input type="text" name="title" />
{fetcher.state !== "idle" && <p>Saving...</p>}
{fetcher.data?.error && (
<p style={{ color: "red" }}>
{fetcher.data.error}
</p>
)}
</fetcher.Form>
</div>
);
}
You can use form actions, useActionState
, and useOptimistic
instead of fetchers with React 19.
clientAction
moves into a React action function but goes mostly unchangeduseRevalidator
useActionState
provides the pending flag instead of fetcher.state
useOptimistic
provides the optimistic value instead of reading from fetcher.formData
import {
useLoaderData,
useRevalidator,
} from "react-router";
import { useActionState, useOptimistic } from "react";
async function updateTitleAction(formData: formData) {
await new Promise((res) => setTimeout(res, 1000));
let data = await request.formData();
let title = data.get("title") as string;
if (title.trim() === "") {
return { ok: false, error: "Title cannot be empty" };
}
localStorage.setItem("title", title);
return { ok: true, error: null };
}
export async function clientLoader() {
// ...
}
export default function Component() {
let data = useLoaderData();
let revalidator = useRevalidator();
let [title, setTitle] = useOptimistic(data.title);
let [state, action, pending] = useActionState(
async (_prev: any, formData: FormData) => {
setTitle(formData.get("title") as string);
let result = await updateTitleAction(formData);
if (result.ok) await revalidator.revalidate();
return result;
},
null
);
return (
<div>
<h1>{title}</h1>
<form action={action}>
<input type="text" name="title" />
{pending && <p>Saving...</p>}
{state?.error && (
<p style={{ color: "red" }}>{state.error}</p>
)}
</form>
</div>
);
}
Another common use case for fetchers is to load data from a route for something like a combobox.
Consider the following route with a very basic search:
// { path: '/search-users', filename: './search-users.tsx' }
const users = [
{ id: 1, name: "Ryan" },
{ id: 2, name: "Michael" },
// ...
];
export async function loader({ request }) {
await new Promise((res) => setTimeout(res, 300));
let url = new URL(request.url);
let query = url.searchParams.get("q");
return users.filter((user) =>
user.name.toLowerCase().includes(query.toLowerCase())
);
}
import { useFetcher } from "react-router";
export function UserSearchCombobox() {
let fetcher = useFetcher();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input type="text" name="q" />
</fetcher.Form>
</div>
);
}
import { useFetcher } from "react-router";
import type { Search } from "./search-users";
export function UserSearchCombobox() {
let fetcher = useFetcher<typeof Search.action>();
// ...
}
Ensure you use import type
so you only import the types.
import { useFetcher } from "react-router";
export function UserSearchCombobox() {
let fetcher = useFetcher<typeof Search.action>();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input type="text" name="q" />
</fetcher.Form>
{fetcher.data && (
<ul>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
Note you will need to hit "enter" to submit the form and see the results.
import { useFetcher } from "react-router";
export function UserSearchCombobox() {
let fetcher = useFetcher<typeof Search.action>();
return (
<div>
<fetcher.Form method="get" action="/search-users">
<input type="text" name="q" />
</fetcher.Form>
{fetcher.data && (
<ul
style={{
opacity: fetcher.state === "idle" ? 1 : 0.25,
}}
>
{fetcher.data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}
Fetchers can be submitted programmatically with fetcher.submit
:
<fetcher.Form method="get" action="/search-users">
<input
type="text"
name="q"
onChange={(event) => {
fetcher.submit(event.currentTarget.form);
}}
/>
</fetcher.Form>
Note the input event's form is passed as the first argument to fetcher.submit
. The fetcher will use that form to submit the request, reading it's attributes and serializing the data from it's elements.
When using React 19, you can do the same thing with form actions, useActionState
, and useTransition
without needing to configure a route for the search.
const users = [
{ id: 1, name: "Ryan" },
{ id: 2, name: "Michael" },
// ...
];
async function search(_: any, formData: FormData) {
let query = formData.get("q") as string;
await new Promise((res) => setTimeout(res, 250));
return users.filter((user) =>
user.name.toLowerCase().includes(query.toLowerCase())
);
}
import { useActionState, useTransition } from "react";
import { searchUsers } from "./search-users";
export default function UserSearchCombobox() {
let [, startTransition] = useTransition();
let [data, action, pending] = useActionState(
searchUsers,
null
);
return (
<div>
<form action={action}>
<input
type="text"
name="q"
onChange={(event) => {
startTransition(() => {
action(
new FormData(event.currentTarget.form!)
);
});
}}
/>
</form>
{data && (
<ul style={{ opacity: pending ? 0.25 : 1 }}>
{data.map((user) => (
<li key={user.id}>{user.name}</li>
))}
</ul>
)}
</div>
);
}