| | |

React Router 13 ⚛️ Pending States — Managing submission states

⏱️ Estimated read: 10 min | |

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

ValueMeaning
"idle"No navigation
"submitting"Action in progress
"loading"Loader in progress

Navigation Properties

PropertyPurpose
stateThe current state
formDataSubmitted data
locationDestination
formActionAction URL
formMethodHTTP method

Fetcher Properties

PropertyPurpose
stateThe fetcher state
dataThe action result
FormThe form component
submitThe submission function

Hooks

HookPurpose
useNavigationGlobal state
useFetcherLocal state
useSubmitImperative 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

PitfallWhy It HappensFix
Form not disabledNo isSubmittingUse navigation.state
Double submitNo disablingDisable the button
Like disables the pageUsed navigationUse useFetcher
Optimistic UI not showingNo formDataRead navigation.formData
Spinner on wrong routeUsed fetcher stateUse 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

ItemValue
Global pendinguseNavigation()
Submission statenavigation.state === "submitting"
Loading statenavigation.state === "loading"
Form datanavigation.formData
Destinationnavigation.location
Local pendinguseFetcher()
Fetcher formfetcher.Form
Fetcher datafetcher.data
Imperative submituseSubmit()

Key takeaways:

  • The useNavigation is 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.formData is the optimistic UI source. The submitted title is available before the action completes. The component renders the expected item with the pending styling.
  • The useFetcher is the local pending state. The fetcher’s state is 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.data is 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 useSubmit is 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.

Was this helpful?


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!