React Router 13 ⚛️ Pending States — Managing submission states
The previous chapter covered the form actions that handle the data mutations. This chapter covers the UI that appears while the action (or the loader) is in progress: the pending state. In the React Router’s Data mode, the useNavigation hook returns the current navigation state, which is the single source of truth for the pending UI. The state is "idle", "submitting", or "loading". The "submitting" is the action in progress (the form submission). The "loading" is the loader in progress (the data fetch after the action, or the navigation to a new route). The critical distinction is between the navigation-level pending state and the fetcher-level pending state. The useNavigation tracks the global navigation. The useFetcher tracks a specific fetcher. The exam tests the useNavigation state and the useFetcher state, and the difference between the two.
Key point: The useNavigation() hook returns the navigation state. The navigation.state is "idle", "submitting", or "loading". The navigation.formData is the submitted form data during the submission. The navigation.location is the destination during the loading. The useFetcher() hook returns a fetcher with its own state, which is independent of the main navigation. The fetcher is the correct choice for the non-navigational mutations (the “like” button, the inline edit, the background sync). The useSubmit() hook is the imperative submission for the custom controls.
Why pending states require a specific mental model
The three-state problem. The navigation state has three values. The "idle" is the no-operation state. The "submitting" is the state while the action is running. The "loading" is the state while the loader is running, which happens after the action completes or when the user navigates to a new route. The component that checks only for the "submitting" state misses the "loading" state, which can be just as long. The correct check is navigation.state !== "idle".
The global versus local problem. The useNavigation is the global state. It reflects the current navigation of the whole app. The useFetcher is the local state. It reflects the specific fetcher’s request. The “like” button should use the fetcher, not the navigation. The “like” button should not disable the other links on the page. The navigation is for the route change; the fetcher is for the in-place mutation.
The formData problem. The navigation.formData is the submitted form data. The component can use it for the optimistic UI. The navigation.formData.get("title") returns the submitted title, which can be rendered immediately before the action completes. The optimistic UI is the feature that makes the pending state feel instant.
The location problem. The navigation.location is the destination during the loading. The component can use it to show the “loading the new page” indicator. The navigation.location.pathname is the URL. The navigation.location.search is the search params.
The useSubmit problem. The useSubmit hook returns a function that submits a form programmatically. The function accepts the FormData or the HTMLFormElement. The submit(formData, { method, action }) is the imperative submission. The use case is the custom control (e.g., the slider that submits on change, the hotkey that saves). The useSubmit is the imperative; the <Form> is the declarative.
The trade-off. The navigation-level pending UI is the simplest and the correct choice for the route-changing forms. The fetcher-level pending UI is the correct choice for the in-place mutations that should not affect the rest of the page. The two are complementary.
a. The useNavigation and the global pending state
The useNavigation hook returns the current navigation state.
import { Form, useNavigation } from "react-router";
function NewTodo() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const isNavigating = navigation.state === "loading";
return (
<Form method="post">
<input name="title" disabled={isSubmitting} />
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating..." : "Create"}
</button>
{isNavigating && <p>Loading the new page...</p>}
</Form>
);
}
The navigation.state === "submitting" disables the form. The navigation.state === "loading" shows the loading indicator for the new route. The two states are the different phases of the same submission.
The navigation.formData is the submitted data during the submission.
function NewTodo() {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const optimisticTitle = navigation.formData?.get("title");
return (
<div>
{isSubmitting && <p>Submitting: {optimisticTitle}</p>}
<Form method="post">
<input name="title" />
<button>Create</button>
</Form>
</div>
);
}
The navigation.formData.get("title") returns the submitted title before the action completes. The optimistic UI shows the title immediately, which reduces the perceived latency.
The navigation.location is the destination during the loading.
function NavBar() {
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
return (
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
{isLoading && <span className="spinner" />}
</nav>
);
}
The global spinner shows during the navigation to any route. The spinner is the top-bar indicator.
b. The useFetcher and the local pending state
The useFetcher hook returns a fetcher with its own state, which is independent of 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 action without affecting the main navigation. The fetcher.state is "idle", "submitting", or "loading". The fetcher.state === "submitting" disables the button during the submission. The rest of the page is unaffected.
The fetcher.data is the action return value. The fetcher.Form re-runs the action, and the component reads the result.
function LikeButton({ postId }) {
const fetcher = useFetcher();
const isLiked = fetcher.data?.liked ?? false;
return (
<fetcher.Form method="post" action={`/posts/${postId}/like`}>
<button type="submit">{isLiked ? "Unlike" : "Like"}</button>
</fetcher.Form>
);
}
The fetcher.data.liked is the action result. The button label changes based on the result. The data is available after the action completes.
The useFetcher is the correct choice for the like button, the inline edit, and the background sync. The navigation is for the route change.
c. The useSubmit and the optimistic UI
The useSubmit hook returns a function that submits a form programmatically.
import { useSubmit, useNavigation } from "react-router";
function SearchBar() {
const submit = useSubmit();
const navigation = useNavigation();
const handleChange = (event) => {
const form = event.currentTarget.form;
submit(form);
};
return (
<Form method="get">
<input
name="query"
onChange={handleChange}
disabled={navigation.state === "loading"}
/>
</Form>
);
}
The submit(form) submits the form on every change. The Form method="get" is the search, which updates the URL search params and re-runs the loader. The navigation.state disables the input during the loading.
The optimistic UI is the pattern where the component renders the expected state before the action completes.
function TodoList({ todos }) {
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
const newTitle = navigation.formData?.get("title");
const displayTodos = isSubmitting && newTitle
? [...todos, { id: "temp", title: newTitle, pending: true }]
: todos;
return (
<ul>
{displayTodos.map((todo) => (
<li key={todo.id} className={todo.pending ? "pending" : ""}>
{todo.title}
</li>
))}
</ul>
);
}
The displayTodos includes the new todo with the submitted title. The pending class styles the item as pending. The list shows the new item before the action completes. When the action completes, the loader re-runs, and the temporary item is replaced by the real one.
Complete Example Session
# ============================================
# PART 1: GLOBAL PENDING STATE
# ============================================
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
# ============================================
# PART 2: DISABLE FORM
# ============================================
<input disabled={isSubmitting} />
<button disabled={isSubmitting}>Create</button>
# ============================================
# PART 3: FORM DATA
# ============================================
const title = navigation.formData?.get("title");
# ============================================
# PART 4: LOADING STATE
# ============================================
const isLoading = navigation.state === "loading";
{isLoading && <Spinner />}
# ============================================
# PART 5: LOCAL FETCHER STATE
# ============================================
const fetcher = useFetcher();
const isSubmitting = fetcher.state === "submitting";
# ============================================
# PART 6: FETCHER FORM
# ============================================
<fetcher.Form method="post" action="/like">
<button>{isSubmitting ? "..." : "Like"}</button>
</fetcher.Form>
# ============================================
# PART 7: FETCHER DATA
# ============================================
const result = fetcher.data;
# ============================================
# PART 8: USE SUBMIT
# ============================================
const submit = useSubmit();
submit(form);
# ============================================
# PART 9: OPTIMISTIC UI
# ============================================
const optimistic = navigation.formData?.get("title");
const display = isSubmitting ? [...items, { title: optimistic }] : items;
The nine parts trace the pending state: global state, disable, form data, loading, fetcher state, fetcher form, fetcher data, useSubmit, and optimistic.
Quick Reference
Navigation State
| Value | Meaning |
|---|---|
"idle" | No navigation |
"submitting" | Action in progress |
"loading" | Loader in progress |
Navigation Properties
| Property | Purpose |
|---|---|
state | The current state |
formData | Submitted data |
location | Destination |
formAction | Action URL |
formMethod | HTTP method |
Fetcher Properties
| Property | Purpose |
|---|---|
state | The fetcher state |
data | The action result |
Form | The form component |
submit | The submission function |
Hooks
| Hook | Purpose |
|---|---|
useNavigation | Global state |
useFetcher | Local state |
useSubmit | Imperative submit |
Best Practices
✅ Do This:
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
<!-- Use the state for the disabling --> # ✅
const fetcher = useFetcher();
<!-- Use the fetcher for the in-place mutation --> # ✅
const title = navigation.formData?.get("title");
<!-- Use the formData for the optimistic UI --> # ✅
❌ Don’t Do This:
# Use useState for the pending state
<!-- The navigation state is the source --> # ❌
# Use the navigation for the like button
<!-- It disables the whole page --> # ❌
# Forget the loading state
<!-- It is the separate phase --> # ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Form not disabled | No isSubmitting | Use navigation.state |
| Double submit | No disabling | Disable the button |
| Like disables the page | Used navigation | Use useFetcher |
| Optimistic UI not showing | No formData | Read navigation.formData |
| Spinner on wrong route | Used fetcher state | Use navigation state |
Real-World Examples
1. Disable Submit
<button disabled={navigation.state === "submitting"}>Save</button>
2. Global Spinner
{navigation.state === "loading" && <Spinner />}
3. Form Data
const title = navigation.formData?.get("title");
4. Fetcher
const fetcher = useFetcher();
5. Fetcher Form
<fetcher.Form method="post" action="/like">
6. Fetcher State
const isSubmitting = fetcher.state === "submitting";
7. Fetcher Data
const result = fetcher.data;
8. Use Submit
const submit = useSubmit();
submit(form);
9. Optimistic UI
const optimistic = navigation.formData?.get("title");
10. Loading State
navigation.state === "loading"
Visual
Navigation State Flow
┌──────────────────────────────────────────────┐
│ "idle" │
│ → User fills the form │
└──────────────────┬───────────────────────────┘
│ submit
▼
┌──────────────────────────────────────────────┐
│ "submitting" │
│ → Action runs │
│ → Disable the form │
└──────────────────┬───────────────────────────┘
│ action completes
▼
┌──────────────────────────────────────────────┐
│ "loading" │
│ → Loader re-runs │
│ → Show the spinner │
└──────────────────┬───────────────────────────┘
│ loader completes
▼
┌──────────────────────────────────────────────┐
│ "idle" │
│ → New content rendered │
└──────────────────────────────────────────────┘
Navigation vs Fetcher
┌──────────────────────────────────────────────┐
│ Navigation (useNavigation) │
│ → Route change │
│ → Global state │
└──────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────┐
│ Fetcher (useFetcher) │
│ → In-place mutation │
│ → Local state │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Global pending | useNavigation() |
| Submission state | navigation.state === "submitting" |
| Loading state | navigation.state === "loading" |
| Form data | navigation.formData |
| Destination | navigation.location |
| Local pending | useFetcher() |
| Fetcher form | fetcher.Form |
| Fetcher data | fetcher.data |
| Imperative submit | useSubmit() |
Key takeaways:
- The
useNavigationis the global pending state. It has three values:"idle","submitting", and"loading". The"submitting"is the action in progress. The"loading"is the loader in progress. The component checks both for the correct UI. - The
navigation.formDatais the optimistic UI source. The submitted title is available before the action completes. The component renders the expected item with the pending styling. - The
useFetcheris the local pending state. The fetcher’sstateis independent of the main navigation. The fetcher is the correct choice for the in-place mutations that should not disable the rest of the page. - The
fetcher.datais the action result. The component reads the result after the action completes. The result can toggle the UI state, such as the “liked” flag. - The
useSubmitis the imperative submission. The function submits a form programmatically from the event handler. It is the correct choice for the custom controls that submit on change or on a hotkey.
Remember: The navigation is the global. The fetcher is the local. The submitting is the action. The loading is the loader. The formData is the optimistic. The useSubmit is the imperative. The exam tests the navigation state and the fetcher distinction.
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!