Loading, Error, and Previous Data States

Purpose and Scope

This page explains the user-facing state transitions that make useSWR feel responsive while still keeping data fresh. In SWR terminology, a request is identified by a key, resolved by a fetcher, stored in cache, and then exposed to React components through values such as data, error, isLoading, and isValidating. The tests for loading, error, promise fallback, and previous-data behavior show how those values change over time and what developers can rely on when rendering loading indicators, error views, and stale-but-useful data during revalidation.

Sources: test/use-swr-loading.test.tsx, test/use-swr-error.test.tsx, test/use-swr-promise.test.tsx, test/use-swr-laggy.test.tsx

The central idea is that loading and validating are related but not identical. isLoading describes the initial user-visible state where no resolved data has been provided for the current key yet. isValidating describes an in-flight request or revalidation, including cases where a component may already have data. The loading tests demonstrate the initial transition from data being absent to data being available, and they separately assert the first render can show either a loading or validating label before the hook settles into a ready state.

SWR also treats rendering as a subscription to the specific fields a component reads. The loading tests include cases where a component reads only data, or even calls useSWR without reading returned state, and the assertions confirm that SWR avoids unnecessary rerenders while the fetch still happens. This matters for production components because adding isValidating to a render path is a deliberate UI choice: it can display progress for background work, but it also means the component participates in that state change.

Relevant Source Files

  • test/use-swr-loading.test.tsx - Covers initial isLoading and isValidating transitions, render-count behavior, fetching without subscribing to returned fields, fallback-data equality, and enumerable return object expectations.
  • test/use-swr-error.test.tsx - Covers rejected fetcher results, error rendering, onError, onErrorRetry, visibility-aware retry behavior, and shouldRetryOnError: false.
  • test/use-swr-promise.test.tsx - Covers promises supplied through global fallback, per-hook fallbackData, Suspense while fallback promises resolve, and fallback promise errors reaching an error boundary.
  • test/use-swr-laggy.test.tsx - Covers keepPreviousData when keys change, shared-cache behavior, interaction with fallbackData, and latest-data behavior after bound mutate.

Loading and Validating States

The simplest state machine for a fresh key is visible in the loading tests. A component calls useSWR(key, () => createResponse('data')) and renders data with either isValidating or isLoading. On the initial render, data is still absent and the boolean state is true, producing output like a loading or validating message. After the fetcher resolves, the component rerenders with data set to the response and the boolean state false. The tests assert two renders for these straightforward cases, which gives a practical baseline for application code that gates a skeleton, spinner, or empty state on those fields.

Sources: test/use-swr-loading.test.tsx

A common mistake is to treat isValidating as a replacement for isLoading. The tests make a useful distinction: the initial request can be both a validating period and a loading period, but later revalidations may not mean the UI has no data. If your component already has cached data, a background fetch can be represented as validation without erasing the current view. That is why a page can render useful cached content while still showing a small refresh indicator, rather than replacing the whole view with a full-page loading state.

The loading tests also show that SWR is optimized around what the component actually consumes. In one case, the component destructures only data, and a comment explains that not accessing isValidating means validating changes should not trigger an extra rerender. In another case, the component calls useSWR and reads none of the returned state, yet the fetcher still runs and sets a flag when data loads. This is important for advanced patterns such as preloading, warming cache, or coordinating fetches where the component does not need to repaint for every internal transition.

Fallback data adds another nuance. A test configures fallbackData: { greeting: 'hello' }, then later calls mutate with a response that is structurally the same greeting. The test expects the fetches to have occurred but the render count to remain one. The practical lesson is that supplying fallback data can make the UI ready immediately, and if subsequent cache values compare as unchanged for the observed state, the component does not need to rerender just because network work happened.

Error States and Retry Flow

Error handling begins with the fetcher result. The error tests use a fetcher that returns an Error('error!') through the test response helper, then render error.message when error is present. The component first renders its normal empty-data view and later renders the error message after the request settles. For application code, this supports the familiar pattern of checking error before rendering the successful data view, while still allowing the first paint to be a neutral or loading state.

Sources: test/use-swr-error.test.tsx

SWR exposes lifecycle callbacks so that error state can be observed outside the component tree. The tests include onError, which receives the failing key and records it for assertion. This is the hook point for logging, metrics, toast notifications, or centralized reporting. Because the key is passed through, handlers can distinguish which resource failed without depending on component-local state. In a larger app, that is often preferable to scattering reporting logic throughout every component that renders an error branch.

Retry behavior is configurable and intentionally tied to environment state. One test defines onErrorRetry and calls the provided revalidate function after a timeout, producing sequential messages such as error: 0, error: 1, and error: 2. Another test hides the document with a visibility helper and verifies that retrying stops while the document is not visible. A separate test sets shouldRetryOnError: false and confirms that the first error remains unchanged after waiting. These cases define the practical knobs: custom retry scheduling, visibility-aware retry suppression, and an explicit off switch.

When designing error UI, avoid assuming that an error is terminal. With retries enabled, error can represent the latest failed attempt while SWR is still allowed to revalidate. With retries disabled, it may remain stable until the user or application triggers another fetch. Good interfaces make that distinction visible: a retrying error might say that the app is trying again, while a disabled-retry error might offer a manual button wired to mutate or another revalidation trigger.

Promise Fallbacks and Suspense

The promise tests cover a more advanced path: fallback values can themselves be promises. A global SWRConfig fallback object maps the key to a promise resolving to initial data, while the hook fetcher separately resolves to new data. The expected sequence is that the page first shows the initial fallback result and then updates after the hook request finishes. The same pattern is tested with per-hook fallbackData, giving developers two places to inject a pending value depending on whether the seed data is global to a subtree or local to one hook call.

Sources: test/use-swr-promise.test.tsx

Suspense changes how unresolved fallback promises appear to the user. In the Suspense test, the component is wrapped in <Suspense fallback={<div>loading</div>}>, and the page first renders the Suspense fallback while the fallback promise resolves. Once the fallback promise resolves, the component can show data:initial data; after the fetcher completes, it shows data:new data. This sequence is different from a plain isLoading branch because React, rather than the component's own conditional rendering, controls the temporary fallback view.

The promise tests also cover errors from fallback promises by wrapping the configuration in an ErrorBoundary. That establishes an important boundary between recoverable fetch errors returned through useSWR state and promise errors thrown during Suspense-style resolution. When using promises as fallback values, choose the surrounding React error and Suspense boundaries deliberately. A component that expects error in the hook return may not be the only place where failures surface if the promise is being resolved through React's Suspense mechanism.

Previous Data and Laggy UI

keepPreviousData is the option that turns a key change into a smoother transition. The laggy tests create an initial key, let it resolve, then switch to a new key. With keepPreviousData: true, the logged sequence shows the new key paired with the previous key's data while the new fetch is still pending, and then the new key paired with its own data after resolution. This is useful for search, filters, tabs, and pagination where clearing the current content during every key change would cause a jumpy interface.

Sources: test/use-swr-laggy.test.tsx

The shared-cache test makes the behavior easier to reason about. Two hooks read the same key: one normal useSWR, and one configured with keepPreviousData. After the key changes, the normal hook returns undefined until the new request resolves, while the lagged hook keeps the previous value. Once the new data arrives, both hooks converge on the new value. This shows that previous-data behavior is a view-level policy layered over the cache, not a guarantee that every hook reading the key will see lagged data.

Fallback data and previous data can coexist, but previous data wins during the important transition. The laggy tests set fallbackData: 'fallback' on both the normal and lagged hooks. On the first key, both start with fallback and then resolve to real data. After changing the key, the normal hook returns fallback again for the new key, while the lagged hook keeps the previous real value until the new fetch completes. This is a helpful distinction for forms and list views: fallback data is a seed for an unresolved key, while keepPreviousData preserves continuity across key changes.

Mutation introduces one more state transition. In the laggy tests, the hook exposes mutate; after the second key resolves, a click calls mutate('mutate'). The logged sequence shows the local mutation value, followed by the fetched value again. That sequence is a reminder that bound mutation can immediately change what the hook returns, but revalidation may later replace the optimistic or local value with the canonical fetcher result. UI that uses both keepPreviousData and mutate should be explicit about whether it is showing previous, optimistic, or freshly revalidated data.

Compact State Reference

SurfaceMeaning in these testsTypical UI use
dataThe latest resolved value for the key, a fallback value, a resolved fallback promise, previous data, or a local mutation result depending on the transition.Render the successful view when available.
errorThe latest fetch or promise failure exposed to the component or boundary.Render failure UI, logging, and retry affordances.
isLoadingTrue before usable data has resolved for the current loading path.Full-page loading states and skeletons.
isValidatingTrue while SWR is fetching or revalidating.Background refresh indicators.
fallbackDataPer-hook seed data, including promises in the tested behavior.Avoid empty first paint for one hook.
fallbackGlobal key-to-value seed data through SWRConfig, including promises in the tested behavior.Seed a subtree or hydrate known cache entries.
keepPreviousDataKeeps the prior resolved value visible while a new key is loading.Smooth key transitions for filters, search, and pagination.
onErrorCallback invoked for a failing request and key.Centralized logging or notifications.
onErrorRetryCallback that can schedule revalidate.Custom retry timing and backoff.
shouldRetryOnErrorBoolean switch that can disable retry.Stop automatic retries for known terminal failures.

Implementation Signals for Application Code

The tests collectively point to a practical rendering order: handle fatal boundary-level failures with React boundaries when using Suspense or promise fallbacks, handle hook-level error in component UI, show isLoading only when the page truly lacks usable data, and reserve isValidating for background refresh state. If a key can change rapidly, add keepPreviousData when continuity matters more than showing a blank or fallback placeholder. If a mutation is local or optimistic, expect a later fetch to confirm or replace that value.

For next steps, read the useSWR API page for the full hook contract, the global configuration page for SWRConfig, and the mutation concepts page for optimistic updates and revalidation after cache writes. When debugging a specific component, reproduce the state sequence by logging the key, data, error, isLoading, and isValidating together; the test cases on this page show that the same key can pass through several valid states before the UI becomes ready.