Loading States
Visual stability means that a page’s layout remains predictable and free of unexpected shifts as content loads. Loading states help achieve this and provide users with clear visual feedback about ongoing asynchronous operations, such as data fetching. React Router exposes two distinct mechanisms for managing loading states: declarative and imperative. Both mechanisms serve the same user-facing goal, communicating that an operation is in progress. They differ in where and how the loading state is defined and consumed. The appropriate choice depends on the scope and trigger of the async operation.
This documentation builds on Data Fetching. In the context of classifying data as critical and non-critical, the term visual stability was mentioned in Data Fetching, along with its relevance to user-perceived, or psychological, performance characteristics of a web app.
Declarative loading states are defined structurally in the component tree via React’s <Suspense/> component.
Suspense acts as an asynchronous boundary: it intercepts rendering of any child component that isn’t yet ready and renders a fallback UI in its place until the operation completes. A child component signals that it’s not ready by throwing a Promise. Native React concepts, such as the use() hook, implement that convention, but so do React Router’s built-in data streaming or <Await/> patterns.
Suspense integrates with React’s concurrent rendering model, which enables React to pause and resume rendering work without blocking the main thread. This is the foundational mechanism that enables both visual stability and responsive loading states in modern React apps.
To opt into a declarative loading state, a route loader returns a Promise without awaiting it. React Router exposes this unresolved Promise via the route’s loader data, which is then passed downstream for resolution in the component tree. A Suspense boundary renders the fallback until that Promise settles. Critical data is awaited in the loader and available immediately. Only non-critical data is deferred to a declarative loading state.
React 19 introduced the use() hook, which reads a Promise directly inside a component. When the Promise isn’t yet settled, use() suspends the component, triggering the nearest Suspense boundary without requiring any additional abstraction.
While use() is portable and framework-agnostic, it has two relevant limitations in this context: it requires the consuming logic to be extracted into a separate component, and provides no built-in mechanism for co-locating error handling with the loading boundary.
React Router’s Await component addresses both limitations. It resolves the Promise inline via a render prop, eliminating the need for a separate component, and accepts an optional errorElement prop that catches rejections locally, keeping the loading and error boundary co-located with the async operation. For these reasons, Await is the recommended pattern when working within React Router’s data layer.
When a Promise passed to Await rejects, the error propagates to the nearest error boundary in the tree. For more granular control, Await accepts an optional errorElement prop that catches rejections locally without affecting the rest of the route.
Multiple independent <Suspense> boundaries can coexist within a single route, each encapsulating one async operation. Conceptually, each <Suspense> boundary acts like a try/catch for async. Everything inside the boundary is treated as a single loading unit, and content outside the boundary isn’t affected by the suspensions within it.
This is why each promise must be wrapped in its own <Suspense> boundary when a component consumes multiple asynchronous data sources. This applies to React Router’s <Await>, React’s use(), and any other suspending mechanism. If multiple promises share a single boundary, the faster promise resolves first and triggers a re-render. The still-pending slower promise then suspends the boundary again. Already-rendered content is torn down, and the fallback reappears. The result is visible content flicker and layout shift, which inflates interactivity metrics like Interaction to Next Paint (INP).
With separate boundaries, each promise resolves independently. Fast content streams in and stays visible. Slower content continues loading with its own skeleton. For more details, see the React 18 architecture discussion on GitHub: New Suspense SSR Architecture in React 18.
Exception: Truly dependent promises (for example, fetching details after a list) can share a boundary because they represent one logical loading unit.
A <Suspense> boundary identifies a pending promise by its reference, not by its value. When a child suspends, the boundary remembers that exact promise object and waits for it to settle. On the next render, if the child hands back a different promise object — even one that resolves to the same data — the boundary treats it as new pending work, throws away what it just rendered, and shows the fallback again. With a fresh promise on every render, the cycle never ends and the fallback flickers forever. This applies equally to React’s use() and React Router’s <Await>.
So the rule is: the same logical operation must produce the same promise object across renders.
Promises returned from a route loader satisfy this automatically — React Router preserves their identity for the lifetime of the active route match, whether read via useLoaderData, useRouteLoaderData, or useOutletContext. Anything composed in the component body with Promise.all, Promise.race, .then(...), or any wrapper expression does not: the expression is evaluated on every render, producing a brand-new Promise object each time.
useMemo(() => Promise.all([p1, p2]), [p1, p2]) doesn’t fix this: React discards the memo cache when the component suspends on initial mount (see useMemo Caveats, “a state variable or a ref may be more appropriate”. Failure mode is confirmed in remix-run/remix#7392).
If the promises form one logical loading unit, combine them in the loader so loaderData exposes a single stable reference.
If the promises don’t truly form one loading unit, give each its own <Suspense> boundary so they resolve independently (see Suspense Boundary Granularity). No composition needed.
Use only when neither primary fix applies (for example, the inputs arrive from a parent layout’s useOutletContext, props, or fetcher hooks, and the consuming component must combine them). The examples assume p1 and p2 are already stable references; how they’re obtained doesn’t matter.
Variant A — lazy useState pin. Frozen for the component’s lifetime; invalidate by remounting via <Component key={inputIdentity} />.
Variant B — useRef pin with manual re-pin. Use when the consumer can’t be remounted via key (for example, inputs change on revalidation while the component stays mounted). Repins when input identity changes. Survives Suspense throws.
The same rule applies to use() wrappers: a child that calls use(somePromise) must receive a stable promise reference as a prop. Constructing the promise in the parent’s render (for example, <Child promise={Promise.all([a, b])} />, <Child promise={a.then(transform)} />) re-suspends on every render for the same reason.
Imperative loading states aren’t defined structurally in the component tree but are read programmatically via hooks. Rather than triggering a Suspense boundary, these hooks expose the current state of an async operation as a discrete value that the component consumes conditionally. This makes them the appropriate choice for scenarios where a Suspense boundary is either unavailable or insufficient, such as global navigation feedback, out-of-band mutations, or manual revalidation. For details on how these hooks relate to the broader state model, see State Management.
| Hook | Scope | State property | Triggers navigation |
|---|---|---|---|
useNavigation() | global / route transition | navigation.state | no (reads state) |
useFetcher() | component-level | fetcher.state | no |
useFetchers() | global (all active) | fetchers[].state | no |
useRevalidator() | current route | revalidator.state | no |
useSubmit() | component-level | via useNavigation() | yes |
useNavigation() reflects the state of the current route transition. It covers the full navigation lifecycle, from when a transition is initiated until the destination route has fully loaded. useNavigation() is typically consumed in a root or layout component to provide a global loading indicator that covers all route transitions uniformly.
navigation.state exposes three values:
idle— no navigation in progressloading— a route transition is in progress and its loader is runningsubmitting— a form submission or action is in progress
useFetcher() enables data fetching and action submissions outside of route transitions, without triggering a navigation. It’s the appropriate mechanism for out-of-band operations such as inline mutations, background data refreshes, or optimistic UI updates. Unlike useNavigation(), useFetcher() is scoped to the specific operation it initiates, making it suitable for granular, component-level loading feedback.
fetcher.state mirrors useNavigation().state in structure:
idle— no operation in progressloading— fetching data from a loadersubmitting— submitting to an action
useFetchers() returns an array of all in-flight fetchers across the application. While useFetcher() is scoped to a single component, useFetchers() provides a global view of all active fetcher operations. This is useful for aggregate loading indicators. For example, showing a single “saving” badge when any background mutation is in progress.
Each entry in the array mirrors the shape of a single useFetcher() instance, exposing state, formData, data, and other properties. The mirroring enables patterns, such as counting active operations or building optimistic lists from multiple concurrent mutations.
useRevalidator() manually triggers revalidation of the current route’s loader data, outside the standard navigation lifecycle. It’s relevant when external events invalidate the current data and a reload is required without a full navigation.
revalidator.state exposes two values:
idle— no revalidation in progressloading— revalidation is running
useSubmit() programmatically triggers form submissions or action calls without requiring a <Form> element. It doesn’t expose its own state. The resulting async operation is reflected in useNavigation().state, which transitions to submitting for the duration of the action. Because useSubmit() delegates state to useNavigation(), it’s typically paired with it explicitly when loading feedback is required.
Visual feedback patterns determine how loading states are communicated to the user. The goal is twofold: signal that an operation is in progress, and maintain visual stability so that the UI doesn’t shift unexpectedly when content resolves. The appropriate pattern depends on the scope of the operation and the nature of the content being loaded.
Spinners communicate that something is happening without making any assumptions about the shape of the incoming content. They’re appropriate for global or indeterminate operations where the layout of the resolved content is unknown or irrelevant.
Skeleton screens reserve the approximate layout of the incoming content before it resolves. This reduces perceived loading time and prevents layout shift, as the resolved content occupies space that was already allocated.
Skeleton screens are preferable when the shape of the resolved content is known and stable. Spinners are sufficient for transient, global, or low-visibility operations where layout continuity isn’t a concern.
Skeletons, like any other React components, are subject to client-side hydration. If skeleton structures are themselves complex, they can carry a non-trivial rendering cost. For content outside the initial viewport, this cost can outweigh the benefit: users don’t perceive layout shift for content they can’t yet see, and the skeleton itself contributes to the hydration workload without improving the visible experience. In these cases, a spinner or a simpler fallback is the more pragmatic choice, even when the structure of the expected content is known.
A global loading indicator communicates the state of the app as a whole, typically a progress bar or spinner in the navigation area. It’s appropriate for route transitions where the entire page content is being replaced.
A local loading indicator is scoped to the specific UI region affected by the operation. It’s appropriate for partial updates, such as deferred data within a route or out-of-band mutations via useFetcher(), where the rest of the UI remains interactive.
Global and local indicators aren’t mutually exclusive. A route transition can warrant both a global progress indicator and local skeleton screens for individual content regions.
Layout shift occurs when content renders into a space that wasn’t reserved for it, causing surrounding elements to reposition. Layout shift degrades both visual stability and user-perceived performance. It’s measured by the Cumulative Layout Shift (CLS) metric.
The primary mitigation is to reserve space for loading content before it resolves. Skeleton screens are the most direct approach, but explicit dimension constraints on container elements achieve the same effect when a skeleton isn’t warranted.
Avoid using fallback={null} without reserving space. Rendering nothing during loading and then injecting content will shift the layout. If no visual fallback is desired, the container must still maintain its dimensions.
Optimistic UI skips the loading state entirely by assuming that an operation will succeed and updating the UI immediately. The actual server response either confirms the update or triggers a rollback. This is the most responsive pattern available, but introduces complexity around error handling and state consistency.
Optimistic UI is appropriate when the probability of failure is low and the operation is reversible. It’s not suitable for destructive or irreversible actions where a failed rollback leaves the UI in an inconsistent state.
useFetcher() exposes submitted form data via fetcher.formData before the action completes, enabling optimistic updates without external state management. For implementation patterns using fetcher.formData and useOptimistic, see State Management.