useFetcher: forms without navigation
After submission, Form behaves like a navigation: the browser history changes and the shared navigation state becomes submitting. For small actions on a page — a comment, a like, "read later" — that is too much. The useFetcher hook gives you a form that calls an action and refreshes the data, but does not change the address and has its own state.
Data for comments
Add to app/data.server.ts:
// app/data.server.ts
export type Comment = { id: number; postId: number; author: string; text: string };
const comments: Comment[] = [];
export async function getComments(postId: number) {
return comments.filter((comment) => comment.postId === postId);
}
export async function addComment(postId: number, author: string, text: string) {
comments.push({ id: comments.length + 1, postId, author, text });
}The post loader and action
In app/routes/post.tsx the loader also returns comments, and a new action saves a comment:
// app/routes/post.tsx
import { addComment, getComments, getPost } from "../data.server";
export async function loader({ params }: Route.LoaderArgs) {
const id = Number(params.postId);
const post = await getPost(id);
if (!post) throw new Response("Post not found", { status: 404 });
return { post, comments: await getComments(id) };
}
export async function action({ request, params }: Route.ActionArgs) {
const formData = await request.formData();
await addComment(
Number(params.postId),
String(formData.get("author")),
String(formData.get("text")),
);
return { ok: true };
}The comment form
// app/routes/post.tsx
import { useFetcher } from "react-router";
export default function Post({ loaderData }: Route.ComponentProps) {
const { post, comments } = loaderData;
const fetcher = useFetcher();
const isSending = fetcher.state !== "idle";
return (
<article>
<h1>{post.title}</h1>
<p>{post.body}</p>
<ul>
{comments.map((c) => (
<li key={c.id}>{c.author}: {c.text}</li>
))}
{isSending && (
<li>{String(fetcher.formData?.get("author"))}: {String(fetcher.formData?.get("text"))}</li>
)}
</ul>
<fetcher.Form method="post">
<input name="author" placeholder="Your name" />
<input name="text" placeholder="Comment" />
<button type="submit" disabled={isSending}>Send</button>
</fetcher.Form>
</article>
);
}Write a comment on /posts/1 — it appears in the list immediately (optimistically, from fetcher.formData), and after the server responds the loader refreshes the list with real data. The address and browser history do not change. Without an action attribute the form submits to the current route's action.
What a fetcher has
| Field | Meaning |
|---|---|
fetcher.Form | A form without navigation |
fetcher.state | idle, submitting or loading — for this form only |
fetcher.formData | The fields being submitted |
fetcher.data | What the action returned, here { ok: true } |
fetcher.submit() | Submitting from code |
fetcher.load(address) | Calling another route's loader |
Common mistake
After submitting with fetcher.Form the fields are not cleared — the page never reloaded. Reset the form yourself: put a useRef on the form and in a useEffect call formRef.current?.reset() when fetcher.state is back to idle and fetcher.data?.ok is true.