Concurrent Mode and Suspense
Explore React's Concurrent Features including Suspense for data fetching, concurrent rendering, transitions, and the latest React 18+ features.
Overview
In this lesson, we'll build a SearchInput component that fetches suggestions as you type, using React's Concurrent Features for a smoother experience. You'll learn how to use Suspense for declarative loading states, leverage transitions (via useTransition and startTransition), defer updates with useDeferredValue, and handle errors gracefully with error boundaries. These tools will make our search component feel fast and responsive even with slow data or heavy computations.
In this lesson:
- Set up a basic search input with state management
- Fetch suggestions asynchronously and display them
- Add Suspense boundaries to show a loading fallback
- Use transitions to keep the UI snappy during updates
- Defer non-urgent updates with useDeferredValue to avoid flicker
- Handle errors with an error boundary for robust UX
Step-by-step: build it right
Starting with a basic search input
Let's begin with a simple search input component. It will manage its own input state and display the query. We'll refine it step by step.
Loading syntax highlighting...
We use a piece of state query to track the input's value. The input is controlled by this state (so it always displays the latest query). As you type, onChange updates the state. Below the input, we simply echo what the user is searching for.
At this stage, the component is static – it doesn't fetch anything yet. Next, we'll connect it to an asynchronous data source to get search suggestions.
Loading suggestions asynchronously
In a real app, typing in a search box might query an API or filter a list. Our goal is to show suggestions (autocomplete results) as the user types. Let's simulate an API by creating a function that returns suggestions after a short delay. Then we'll use a child component to fetch and display those suggestions.
First, a mock API function using setTimeout to mimic network latency:
Loading syntax highlighting...
Now, a SuggestionsList component will use this function. It will accept the current search query as a prop, trigger the fetch when the query changes, and display the results:
Loading syntax highlighting...
This effect triggers whenever query changes. It calls fetchSuggestions(query) and updates local state when results arrive. We use a guard isCurrent to ignore results if the query changed mid-flight (to avoid setting state on an unmounted effect). In the render, while suggestions is null we show a loading message, and once we have data, we render a list or a "no suggestions" note.
Now integrate SuggestionsList into our main component:
Loading syntax highlighting...
Try this out. If you type "ap" you should see suggestions like "apple" appear after a short delay. We've achieved basic async loading with manual state and effects. However, there are issues: the loading UI is managed manually, and if the user types quickly, the UI might feel unresponsive or flicker as suggestions update. We can do better by using React's concurrent features.
Adding Suspense for loading states
React's Suspense API provides a declarative way to handle loading states. Instead of us managing the "loading…" message with boolean flags, we can wrap our suggestions UI in a <Suspense>
boundary with a fallback. When the suggestions are not ready, React will automatically show the fallback.
To use Suspense for data fetching, we need a data source that "suspends" rendering until data is ready. In our case, we can integrate fetchSuggestions with Suspense by creating a small wrapper that throws a Promise. For demonstration, we'll set up a simple cache that holds a promise for each query and throws it while pending:
Loading syntax highlighting...
Here we use React's experimental use(promise) to read the promise (note: use() is available in React 18+ for reading promises in components). If the promise is still pending, React will suspend the component's rendering and show the nearest <Suspense>
fallback. Once the promise resolves, rendering resumes and suggestions is available.
Now we wrap the suggestions in a Suspense boundary in SearchInput:
Loading syntax highlighting...
Now the loading state is handled by Suspense: whenever SuggestionsList is waiting on fetchSuggestions(query), React will display our fallback UI ("Loading suggestions…") instead of the list. We no longer need to manage loading flags or suggestions === null checks inside SuggestionsList – Suspense takes care of it.
How does this help? Suspense declaratively controls the loading state. It also works seamlessly with React's concurrent rendering: content can be streamed in or revealed in chunks without complex state logic on our part. In our search example, as soon as data for query arrives, that list will pop into view and the loading indicator will disappear.
⚠️ Note: The
use()
hook is experimental in React 18. For production apps, consider using established data fetching libraries that support Suspense, or implement your own resource pattern.
Keeping the UI responsive with transitions
Our search component works, but if fetching suggestions or rendering the list is slow, typing quickly could still feel sluggish. This is where transitions come in. React's useTransition hook lets us mark certain state updates as "non-urgent" so they don't block more important updates (like text input). In practice, this means we can keep the input field responsive even while the suggestions list is updating in the background.
We'll use two state variables: one for the input value (updated immediately on every keystroke) and one for the actual query used to fetch suggestions. We'll update the second one inside a transition, slightly delaying it. This way, the input can update instantly, and the expensive work (fetching and rendering suggestions) happens concurrently without holding up the text input.
Loading syntax highlighting...
Now we separated the immediate input state (text) from the deferred query state (query). On each change, we call setText normally so the input reflects the character right away. Then we wrap setQuery in startTransition. This tells React: "update query and everything that depends on it (the suggestions list) at low priority". React will let these transitional updates lag if the user is still typing quickly, focusing first on processing the fast input events.
We also get an isPending boolean from useTransition. This becomes true while a transition is ongoing (i.e. while the query update and subsequent render is in progress). Here, we use it to conditionally show a little "⏳" indicator next to the input, so the user knows suggestions are loading.
Thanks to the transition, typing is smoother. Even if fetching suggestions or rendering a long list takes time, React can interrupt that work to handle new keystrokes promptly. The input won't "stutter." In Ollie's case, this means our owl can type quickly without the UI freezing up.
Under the hood: When we mark updates as a transition, React treats them as interruptible. If a new high-priority update (like another keystroke) comes in, React can pause or discard the in-progress render of the suggestions and restart it after processing the keystroke. The result is a snappier feel for the user. These transition updates are also non-blocking – they won't immediately show a Suspense fallback if they suspend. React knows the content is in transition, so it can keep showing the previous UI until the new content is ready.
⚠️ Heads up: Transitions don't prevent side effects like API calls. If you need to debounce network requests, you'll still need to implement that logic separately.
Showing stale results with deferred values
Currently, when you type a new character, our component clears the old suggestions and shows a loading state until the new results come in. This is fine, but in some cases it's nice to keep showing the old results (grayed out or dimmed perhaps) until the new ones are ready. This avoids a UI flicker where the suggestions list disappears on every keystroke. React's useDeferredValue hook makes this pattern easy.
useDeferredValue lets you take a value and produce a deferred version of it that "lags behind" the original. If we apply it to our search query, we get two versions of the query: one immediate (for the input control) and one deferred (for the suggestions). The deferred value updates more slowly, only when there's time, and it stays at the previous value until the new value's work is done.
We actually already achieved a similar result manually by using two state variables and transitions. We can simplify that by using useDeferredValue on the input state. Let's refactor our component:
Loading syntax highlighting...
Here we removed query state and transitions entirely. We keep one state text for the input. We derive deferredText from it with useDeferredValue. Now, whenever text changes, deferredText will temporarily remain at the old value while React renders an update in the background for the new value. The input shows the latest text immediately (controlled by text), but SuggestionsList is receiving a slightly older value until the new suggestions are ready.
For example, if you type "a", suggestions for "a" load. Then you quickly type "b" to make "ab". The input field will show "ab" right away, but the suggestions list might continue to show results for "a" until the "ab" results arrive. Instead of a loading spinner or empty state, the user sees the stale results from the previous query, which is often better than blanking out the UI.
We can optionally indicate that the results are stale. One simple trick: compare text vs deferredText. If they differ, it means new input hasn't caught up in the suggestions yet, so we could show a subtle loading indicator or dim the list:
Loading syntax highlighting...
This would render the old suggestions semi-transparently and show a message while the new results load. The moment deferredText catches up to text, the new suggestions show and the opacity goes back to 1.
Under the hood, using useDeferredValue is similar to what we did with transitions: it defers the update of the value for the suggestions list. React will continue to show the previous list and only switch to the new one when ready, without triggering the fallback in between. In fact, React specifically integrates deferred values with Suspense to avoid showing the fallback for intermediate states: if a deferred update suspends (e.g. data not ready), React keeps showing the old content instead of a loading indicator. This gives a smooth user experience — no more flickering spinners on each keypress.
Handling errors with an error boundary
So far we've focused on loading states and performance. But what if our data fetch fails? For example, the network might be down or the server returns an error. In such cases, our fetchSuggestions promise would reject. If a component throws an error during rendering (which is what would happen when a Suspense data source errors out), it won't be caught by Suspense. Suspense is for loading states, not errors. We need an Error Boundary to catch errors and show a graceful fallback UI instead of letting the error crash the app.
React error boundaries are special components that catch errors anywhere in their child tree and display a fallback UI. Typically, you define an error boundary as a class component with a componentDidCatch (or static getDerivedStateFromError) to set an error state. For our search, we can create a simple error boundary that wraps the suggestions.
Loading syntax highlighting...
This boundary catches any error thrown in its children. If an error occurs, it renders a message instead (in red text for visibility). Now we can use it in SearchInput:
Loading syntax highlighting...
By wrapping Suspense with an error boundary, we cover both scenarios: if the suggestions data is slow, Suspense shows a loading state; if the fetch fails, the error boundary catches it and shows an error message. Without the error boundary, an error thrown during rendering would unmount the entire Suspense subtree with no user-friendly info. Using both together leads to a robust component that can handle network latency and errors gracefully.
Testing error handling: You might force an error to test this. For example, modify fetchSuggestions to throw if the query is "fail":
Loading syntax highlighting...
Then typing "fail" in the input should eventually display "Failed to load suggestions." from our boundary instead of crashing the app.
Best practices & common mistakes – avoid dumb bugs
- Always wrap Suspenseful components: If a component might suspend (e.g. waiting for data or code), wrap it in
<Suspense fallback={...}>
. Otherwise, nothing will catch the loading delay and your UI might stall or break. Similarly, use error boundaries around Suspense to catch exceptions; Suspense alone won't handle errors. - Don't delay the input update: Never put the input's own state update inside a transition. This will make your text input lag. Use transitions (or deferred values) for the expensive work triggered by the input, not the input control itself.
- Avoid excessive transitions: Only mark truly non-urgent updates as transitions. Overusing startTransition for every state change can make the app feel unresponsive, as all updates get delayed. Use it for things like big lists or heavy recalculations, not trivial UI updates.
- Debounce external requests: Concurrent rendering doesn't magically prevent multiple API calls. If you fire off a request on every keystroke, you might still spam the server. Use useDeferredValue or add your own debounce/throttle logic to limit network calls. Remember, useDeferredValue defers rendering, but it does not prevent extra network requests on each intermediate value.
- Keep fallbacks lightweight: The Suspense fallback should be a simple indicator (spinner, skeleton, message). Complex fallbacks might themselves slow down transitions or block the UI.
Recap
- Built a reusable SearchInput component that fetches suggestions as you type, showing how concurrent features improve UX.
- Used Suspense to declaratively handle loading states, displaying a fallback UI while suggestions load.
- Applied useTransition and startTransition to mark non-critical updates (filtering/fetching suggestions) as low priority, keeping the input field responsive.
- Leveraged useDeferredValue to keep showing previous results until new ones are ready, eliminating flicker and avoiding unnecessary loading spinners.
- Implemented an error boundary to catch errors from our Suspense-enabled component, providing a graceful error message instead of a crash.
With these techniques, Ollie's search component stays snappy and user-friendly. You can now integrate React's concurrent features into your own components to make your app feel faster and more robust under real-world conditions. Happy coding!