Encodera

Thanks for reading!

Donate to the site — we work hard for you.

Contents

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

FieldMeaning
fetcher.FormA form without navigation
fetcher.stateidle, submitting or loading — for this form only
fetcher.formDataThe fields being submitted
fetcher.dataWhat 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.

Thanks for reading!

Donate to the site — we work hard for you.