React Router 14 ⚛️ Fetchers — Background data without navigation
The previous chapter covered the pending states and briefly introduced the useFetcher hook as the local alternative to the global navigation. This chapter expands on the fetchers: the mechanism for loading and mutating the data without changing the route. The LFCS exam does not test React Router, but the front-end engineering certification paths do. The critical distinction is between the navigation and the fetcher. A navigation changes the URL and the route. A fetcher sends the request to the loader or the action, but the URL remains unchanged and the current route remains rendered. The fetcher is the correct tool for the “like” button, the autocomplete search, the inline edit, the background sync, and the form that should not navigate away from the current page. The fetcher.state, the fetcher.data, the fetcher.Form, and the fetcher.load() methods are the complete surface.
Key point: The useFetcher hook returns a fetcher object with the state, the data, the Form component, the load function, and the submit function. The fetcher.Form submits to the action without the navigation. The fetcher.load(href) calls the loader of the route without the navigation. The fetcher.state is "idle", "submitting", or "loading". The fetcher.data is the action or the loader result. The fetcher is independent per component: two fetchers in the same component have the independent state.
Why fetchers require a specific mental model
The navigation versus mutation problem. The <Form> submits to the action and navigates. The useFetcher submits to the action but does not navigate. The difference is the URL. The route-changing mutation (the “create” form that redirects to the list) uses the <Form>. The in-place mutation (the “like” button, the inline toggle) uses the fetcher. The URL is the indicator of the intended behavior.
The multiple fetchers problem. The useFetcher is a hook. Each call returns a separate fetcher. Two fetchers in the same component are independent: one can be submitting while the other is idle. The fetcher.data is per-fetcher, not global. The fetcher.state is per-fetcher. The component that needs two independent mutations uses two useFetcher calls.
The fetcher.load problem. The fetcher.load(href) calls the loader of the route that matches the href. The loader result is available in the fetcher.data. The call does not navigate. The use case is the autocomplete search: the input changes, the fetcher loads the /search?q=... loader, the results render in the dropdown. The URL remains the same.
The revalidation problem. The fetcher submits to the action, and the router revalidates the loaders of the current route. The “like” button adds the like, and the post list revalidates to show the updated count. The fetcher.Form does not navigate, but it still triggers the revalidation. The shouldRevalidate on the loader can prevent the unnecessary revalidation.
The optimistic UI problem. The fetcher’s formData is available during the submission. The component uses the fetcher.formData?.get("field") to render the expected state. The “like” button shows the incremented count immediately, then reconciles with the server when the action completes.
The trade-off. The fetcher is the correct tool for the in-place mutations and the background loads. The navigation is the correct tool for the route changes. The two share the same router, the same loaders, and the same actions. The difference is whether the URL changes.
a. The fetcher.Form and the in-place mutation
The fetcher.Form is the form that submits to the action without the navigation.
import { useFetcher } from "react-router";
function LikeButton({ postId, likes }) {
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit" disabled={isSubmitting}>
{likes} likes
</button>
</fetcher.Form>
);
}
The fetcher.Form submits to the /posts/:id/like action. The action adds the like and returns the new count. The fetcher’s state is "submitting" during the request. The URL remains the same. The rest of the page is unaffected.
The action for the like.
export async function likeAction({ params }) {
await db.likes.increment(params.postId);
return { liked: true };
}
The action returns the result. The component reads it with the fetcher.data.
function LikeButton({ postId }) {
const fetcher = useFetcher();
const isLiked = fetcher.data?.liked ?? false;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button>{isLiked ? "Unlike" : "Like"}</button>
</fetcher.Form>
);
}
The fetcher.data.liked toggles the button label. The result is the action’s return value, not the global state.
b. The fetcher.load and the autocomplete
The fetcher.load(href) calls the loader of the matching route without the navigation.
import { useFetcher } from "react-router";
import { useEffect } from "react";
function SearchBar() {
const fetcher = useFetcher();
const [query, setQuery] = useState("");
useEffect(() => {
if (query.length >= 2) {
fetcher.load(`/search?q=${encodeURIComponent(query)}`);
}
}, [query]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{fetcher.state === "loading" && <span>Loading...</span>}
{fetcher.data?.results && (
<ul>
{fetcher.data.results.map((r) => (
<li key={r.id}>{r.title}</li>
))}
</ul>
)}
</div>
);
}
The fetcher.load calls the /search loader. The loader reads the q param from the request.url. The results are in the fetcher.data.results. The dropdown renders them. The URL of the page remains the same.
The /search loader.
export async function searchLoader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q") || "";
const results = await db.search(q);
return { results };
}
The loader reads the search param. The fetcher passes the param via the URL. The results are the loader return value.
The useEffect re-runs the fetcher on every query change. The fetcher.load is the imperative loader call. The alternative is the <fetcher.Form> with the search input, but the useEffect gives the debouncing control.
The debouncing is the improvement.
useEffect(() => {
const timer = setTimeout(() => {
if (query.length >= 2) {
fetcher.load(`/search?q=${encodeURIComponent(query)}`);
}
}, 300);
return () => clearTimeout(timer);
}, [query]);
The setTimeout delays the load by 300 milliseconds. The clearTimeout cancels the pending load if the query changes again. The debouncing reduces the number of the fetcher calls during the fast typing.
c. The fetcher.submit and the optimistic UI
The fetcher.submit is the imperative submission. It accepts the FormData or the object.
import { useFetcher } from "react-router";
function TodoCheckbox({ todo }) {
const fetcher = useFetcher();
const isCompleted = fetcher.formData
? fetcher.formData.get("completed") === "true"
: todo.completed;
return (
<fetcher.Form method="post" action={`/todos/${todo.id}`}>
<input
type="checkbox"
name="completed"
value="true"
checked={isCompleted}
onChange={(e) => fetcher.submit(e.target.form)}
/>
</fetcher.Form>
);
}
The fetcher.submit submits the form on the checkbox change. The fetcher.formData holds the submitted value during the request. The isCompleted reads the optimistic value: the checkbox shows the new state immediately, then reconciles with the server response.
The optimistic UI is the difference between the perceived instant and the actual network latency. The fetcher.formData is the key.
The fetcher.submit accepts a FormData, an HTMLFormElement, or a plain object.
const handleDelete = () => {
fetcher.submit(
{ id: todo.id },
{ method: "post", action: "/todos/delete" }
);
};
The plain object is converted to the FormData. The method and the action set the request. The fetcher.state tracks the submission.
Complete Example Session
# ============================================
# PART 1: FETCHER FORM
# ============================================
<fetcher.Form method="post" action="/like">
<button>Like</button>
</fetcher.Form>
# ============================================
# PART 2: FETCHER STATE
# ============================================
const isSubmitting = fetcher.state === "submitting";
# ============================================
# PART 3: FETCHER DATA
# ============================================
const result = fetcher.data;
# ============================================
# PART 4: FETCHER LOAD
# ============================================
fetcher.load("/search?q=react");
# ============================================
# PART 5: LOADER
# ============================================
export async function loader({ request }) {
const url = new URL(request.url);
const q = url.searchParams.get("q");
return { results: await search(q) };
}
# ============================================
# PART 6: DEBOUNCING
# ============================================
useEffect(() => {
const t = setTimeout(() => fetcher.load(`/search?q=${query}`), 300);
return () => clearTimeout(t);
}, [query]);
# ============================================
# PART 7: FETCHER SUBMIT
# ============================================
fetcher.submit(form);
# ============================================
# PART 8: OPTIMISTIC
# ============================================
const optimistic = fetcher.formData?.get("completed") === "true";
# ============================================
# PART 9: VERIFY
# ============================================
# Click "Like" → count updates without page change.
# Type in the search → dropdown shows results without navigation.
# Toggle the checkbox → state updates optimistically.
The nine parts trace the fetcher: form, state, data, load, loader, debouncing, submit, optimistic, and verification.
Quick Reference
Fetcher Properties
| Property | Purpose |
|---|---|
state | "idle", "submitting", "loading" |
data | The action or loader result |
formData | The submitted data |
Form | The form component |
load(href) | Imperative loader call |
submit(target, options) | Imperative submit |
Fetcher State
| State | Meaning |
|---|---|
"idle" | No request |
"submitting" | Action in progress |
"loading" | Loader in progress |
Use Cases
| Use Case | Tool |
|---|---|
| Like button | fetcher.Form |
| Autocomplete | fetcher.load |
| Inline edit | fetcher.submit |
| Checkbox toggle | fetcher.submit |
| Background sync | fetcher.load |
Best Practices
✅ Do This:
<fetcher.Form method="post" action="/like">
<!-- Use the fetcher for the in-place mutation --> # ✅
fetcher.load(`/search?q=${query}`)
<!-- Use the load for the autocomplete --> # ✅
const isOptimistic = fetcher.formData?.get("completed") === "true"
<!-- Use the formData for the optimistic UI --> # ✅
❌ Don’t Do This:
# Use <Form> for the like button
<!-- It navigates the page --> # ❌
# Use a single fetcher for the multiple mutations
<!-- The states are shared --> # ❌
# Load without debouncing
<!-- The requests fire on every keystroke --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Like navigates | Used <Form> | Use fetcher.Form |
| Multiple fetchers state confused | Shared variable | Use separate useFetcher |
| Autocomplete slow | No debouncing | Use setTimeout |
| Optimistic not showing | No formData | Read the fetcher.formData |
| Revalidation too frequent | No shouldRevalidate | Add the check |
Real-World Examples
1. Like Button
<fetcher.Form method="post" action="/like"><button>Like</button></fetcher.Form>
2. Fetcher State
const isSubmitting = fetcher.state === "submitting";
3. Fetcher Data
const result = fetcher.data;
4. Load
fetcher.load("/search?q=react");
5. Search Loader
export async function loader({ request }) {
const q = new URL(request.url).searchParams.get("q");
return { results: await search(q) };
}
6. Debouncing
useEffect(() => {
const t = setTimeout(() => fetcher.load(`/search?q=${q}`), 300);
return () => clearTimeout(t);
}, [q]);
7. Submit
fetcher.submit(form);
8. Optimistic
const optimistic = fetcher.formData?.get("field");
9. Multiple Fetchers
const likeFetcher = useFetcher();
const commentFetcher = useFetcher();
10. Checkbox
<fetcher.Form method="post" action="/toggle"><input type="checkbox" /></fetcher.Form>
Visual
Fetcher vs Form
┌──────────────────────────────────────────────┐
│ <Form> │
│ ├── Submits to the action │
│ ├── Changes the URL (navigation) │
│ └── Renders the new route │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ <fetcher.Form> │
│ ├── Submits to the action │
│ ├── Does not change the URL │
│ └── Renders the same route │
└──────────────────────────────────────────────┘
Autocomplete Flow
┌──────────────────────────────────────────────┐
│ User types "re" │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Debounce 300ms │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ fetcher.load("/search?q=re") │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Loader runs → results │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ fetcher.data.results → dropdown renders │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Hook | useFetcher() |
| In-place form | fetcher.Form |
| Loader call | fetcher.load(href) |
| Submission | fetcher.submit(...) |
| State | fetcher.state |
| Data | fetcher.data |
| Submitted data | fetcher.formData |
| Independent state | One per hook call |
Key takeaways:
- The fetcher is the in-place mutation. It submits to the action or loads the loader without changing the URL or the route. The
<Form>is the route-changing mutation. The fetcher is the like button, the autocomplete, the inline toggle. - The
fetcher.Formis the local form. It submits to the action. Thefetcher.stateis"submitting"during the request. The rest of the page is not affected. - The
fetcher.loadis the imperative loader call. The autocomplete uses it to fetch the results without the navigation. The debouncing is the improvement for the fast typing. - The
fetcher.datais the result. The action or the loader return value. The component reads it to update the UI. - The
fetcher.formDatais the optimistic UI source. The submitted data is available before the action completes. The checkbox and the like button render the expected state immediately.
Remember: The fetcher is the local. The form is the global. The fetcher.Form is the mutation. The fetcher.load is the query. The fetcher.state is the status. The fetcher.data is the result. The fetcher is the independence. The exam tests the distinction from the navigation.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!