In React Router 7, why build an inline like button with useFetcher instead of <Form>, and how do you show its pending and optimistic state?
answer
- navigation versus no navigation
- one independent state per button
- idle, submitting, loading, idle
- render from the in-flight fields
basics
~20 sIn React Router 7, a <Form> submission is a navigation to the action's URL and drives page-wide useNavigation state; useFetcher calls the same action without navigating. fetcher.state gives per-button pending UI, and fetcher.formData lets the button render the new value optimistically.
solid answer
~40 sA `<Form method="post" action="/posts/42/like">` is a navigation in React Router 7: after the action runs, the user is taken to that URL, and the whole page's `useNavigation()` state changes. For a button that should stay in place I use `useFetcher()` and its `fetcher.Form` or `fetcher.submit`, which call the same action without touching the URL or history. Each fetcher has its own `state`: `"submitting"` while the action runs, `"loading"` while the router revalidates loaders, then `"idle"`. For optimistic UI I read `fetcher.formData`, which holds the submitted fields until revalidation finishes, so the button shows the new value immediately and then hands over to the fresh loader data without a flicker. If the action fails, `formData` clears and the UI falls back to the loader value.
code
tsx · 21 linesimport { useFetcher } from "react-router";
type Post = { id: string; liked: boolean };
export function LikeButton({ post }: { post: Post }) {
const fetcher = useFetcher();
const pending = fetcher.formData?.get("liked");
const liked = pending != null ? pending === "true" : post.liked;
return (
<fetcher.Form method="post" action={`/posts/${post.id}/like`}>
<input type="hidden" name="liked" value={String(!liked)} />
<button type="submit" aria-pressed={liked}>
{liked ? "Liked" : "Like"}
</button>
{fetcher.state === "idle" && fetcher.data?.error ? (
<span role="alert">{fetcher.data.error}</span>
) : null}
</fetcher.Form>
);
}go deeper
Recall that Form navigates and useFetcher does not, and that fetcher.state tells you when this one button is busy.
Walk through idle, submitting, loading, idle, and explain why fetcher.formData makes optimistic UI possible without extra state.
Show how you handle failure: returned errors on fetcher.data versus thrown errors hitting the boundary, idempotent next-value payloads, and global indicators through useFetchers.
Discuss when optimistic UI is worth its failure modes for a product, and how to keep many concurrent inline mutations consistent with server truth.
## Why `<Form>` is the wrong tool for an inline button In **React Router 7**, a `<Form method="post" action="/posts/42/like">` submission is a **navigation**. The router calls the action of the route at `/posts/42/like`, and then moves the user **to that URL**, just as a browser would after a classic form POST. On a feed with fifty posts that is exactly wrong: the user wanted to stay on the feed. A navigation also drives the page-wide `useNavigation()` state, so every navigation-level spinner on the page lights up for one tiny toggle, and a second like clicked while the first is pending interrupts the first navigation. A plain lowercase `<form>` is worse still: without the router intercepting it, the browser performs a **full document request** and reloads the page. ## What `useFetcher` gives you `useFetcher()` returns an object that talks to loaders and actions **without navigating**: - **`fetcher.Form`**: the same props as `<Form>`, but submitting it does not change the URL or add history. - **`fetcher.submit(data, options)`**: the imperative version, useful from event handlers. - **`fetcher.load(href)`**: calls a route loader without navigating (search-as-you-type, popovers). - **`fetcher.state`**: this fetcher's own status: `"idle"`, `"submitting"` or `"loading"`. - **`fetcher.formData`**: the fields of the submission in flight, while one is in flight. - **`fetcher.data`**: the last value the called action or loader returned. Every `useFetcher()` call that does not share a `key` with another creates an **independent** fetcher, so fifty like buttons each track their own request. Meanwhile `useNavigation().state` stays `"idle"`; a global "saving" indicator can use `useFetchers()` to see every in-flight fetcher instead. ## The fetcher's state machine For a POST through `fetcher.Form`, the states run: 1. **`idle`**: nothing in flight; `formData` is `undefined`. 2. **`submitting`**: the action is running; `formData` holds the submitted fields. 3. **`loading`**: the action has resolved and the router is **revalidating** the page's loaders; `formData` is still set and `data` already holds the action's result. 4. **`idle`** again: revalidated loader data has been committed and `formData` is cleared. Step 3 is what makes optimistic UI flicker-free. Because `formData` survives until revalidation finishes, a button that renders from `formData` keeps showing the new value until the loader's fresh value is there to replace it. ## Building the optimistic like button The button derives what to display from the in-flight submission first, and from loader data otherwise: ```tsx const pending = fetcher.formData?.get("liked"); const liked = pending != null ? pending === "true" : post.liked; ``` It submits the **next** value in a hidden input, so the action is idempotent (set liked to true or false) rather than a blind toggle that double clicks could reverse. Revalidation after the action refreshes the post list, so the like count shown elsewhere catches up without any manual refetch. | Submission path | Navigates | Pending state | Result lands in | |---|---|---|---| | plain `<form>` | full document request | browser only | a reloaded page | | `<Form method="post">` | yes, to the action's URL | `useNavigation()` | `useActionData()` | | `fetcher.Form` / `fetcher.submit` | no | `fetcher.state` | `fetcher.data` | ## When the action fails - If the action **throws**, the fetcher is removed and the error goes to the **nearest route error boundary**, which replaces that route's element. For an inline control this is usually too drastic. - If the action **returns** an error payload, for example `data({ error: "Rate limited" }, { status: 429 })`, it lands on `fetcher.data`, so the button can show a small inline message. - In both cases `formData` is cleared when the fetcher settles, so the optimistic value reverts to whatever the loader data says: the rollback comes for free. ## Where the action lives The fetcher needs a route to post to. The usual shape is an **action-only route** such as `/posts/:postId/like` that has an `action` and no element: nothing ever navigates there, it exists to receive submissions. Omitting `action` on `fetcher.Form` posts to the route that rendered the fetcher instead, which is fine when that route already owns the mutation. Either way the action receives the standard `request` and `params`, reads the body with `await request.formData()`, and returns a plain value or a `data()` result. ## Common mistakes - Using `<Form>` for an in-place toggle, then wondering why the URL changes. - Sending a blind "toggle" payload, so two quick clicks cancel each other out on the server. - Reading `useActionData()` for a fetcher's result; it stays `undefined`. - Keeping a separate `useState` for the optimistic value, which can drift from what the router knows.
- In React Router 7, why does the optimistic like not flicker back to the old value between the action finishing and the list reloading?After the action resolves, the fetcher moves to `"loading"` while loaders revalidate, and it keeps the submission's `formData` during that phase. A button that renders from `formData` therefore keeps showing the new value until revalidated loader data is committed and the fetcher returns to `"idle"`.
- In React Router 7, how would you show one global 'saving' indicator while any of fifty like buttons is in flight?`useNavigation()` will not help, because fetcher submissions are not navigations and leave it `"idle"`. Use `useFetchers()`, which returns the in-flight fetchers, and show the indicator while any of them is not idle.
- In React Router 7, what happens if the like action throws instead of returning an error?A thrown error is a route error: the fetcher is dropped and the nearest route error boundary replaces that route's element. For an inline control, returning an error payload such as `data({ error }, { status: 429 })` is usually better, because it lands on `fetcher.data` and the page keeps rendering.
saying these in an interview costs you the question
- Uses <Form> for an in-place toggle and is surprised the URL changes.
- Reads useActionData() to get a fetcher's action result.
- Expects useNavigation().state to become submitting during a fetcher post.
- Keeps a separate useState for the optimistic value and syncs it by hand.
- Believes fetcher submissions skip loader revalidation.