Skip to main content
Back to Journal
ReactJavaScript

React 18: Concurrent Features and What They Mean for Your App

React 18 shipped in March 2022, and it is the biggest architectural shift since hooks landed in React 16.8. The headline feature is concurrent rendering, but what does that actually mean for the code you write today? After several months of using it in production, I want to break down the features that matter, show real before-and-after examples, and explain the mental model shift that concurrent React introduces.

The createRoot Migration

First things first. To get any of the concurrent features, you need to migrate from ReactDOM.render to createRoot. This is the opt-in mechanism for React 18.

// Before (React 17)
import ReactDOM from 'react-dom';
ReactDOM.render(<App />, document.getElementById('root'));

// After (React 18)
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);

This is not just a syntax change. When you use createRoot, React enables its concurrent renderer. Without this change, React 18 operates in legacy mode and none of the concurrent features activate. The migration is the gateway.

Automatic Batching

In React 17, state updates were batched only inside React event handlers. Updates inside promises, setTimeout, native event handlers, or any other async context were not batched. Each setState call triggered a separate re-render.

// React 17 behavior
function handleClick() {
  // These ARE batched (inside React event handler)
  setCount(c => c + 1);
  setFlag(f => !f);
  // Only one re-render
}

setTimeout(() => {
  // These are NOT batched in React 17
  setCount(c => c + 1); // re-render
  setFlag(f => !f);     // re-render again
}, 1000);

React 18 batches all state updates regardless of where they originate. Inside a promise callback, inside a setTimeout, inside a native event listener. Every state update is batched by default.

// React 18 behavior
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // Only ONE re-render in React 18
}, 1000);

fetch('/api/data').then(() => {
  setData(newData);
  setLoading(false);
  // Only ONE re-render in React 18
});

This is a free performance win. You do not need to change any code. Just migrating to createRoot gives you automatic batching everywhere. If you have a component that sets multiple state variables in a fetch callback, it was re-rendering multiple times in React 17. In React 18, it re-renders once. For the rare case where you need to force a synchronous re-render, React provides flushSync.

Transitions: Urgent vs. Non-Urgent Updates

This is where the concurrent mental model really kicks in. Not all state updates are equally important. Typing in a search box is urgent because the user expects immediate feedback in the input field. Filtering a large list of search results based on that input is less urgent because the user can tolerate a brief delay.

Before React 18, every state update had the same priority. If filtering a list of 10,000 items took 200ms, the input field would freeze for 200ms while React processed the re-render. The UI felt janky.

// Before: Everything is urgent, list filtering blocks input
function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(allItems);

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);  // urgent: update the input
    setResults(filterItems(value));  // expensive: blocks the input
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      <ResultsList items={results} />
    </>
  );
}

With startTransition, you mark the expensive update as non-urgent. React processes the urgent update (typing) immediately and defers the transition (filtering) to a lower priority. If new input arrives while the transition is still processing, React abandons the stale transition and starts a fresh one.

import { useState, useTransition } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState(allItems);
  const [isPending, startTransition] = useTransition();

  function handleChange(e) {
    const value = e.target.value;
    setQuery(value);  // urgent: update immediately

    startTransition(() => {
      setResults(filterItems(value));  // non-urgent: can be interrupted
    });
  }

  return (
    <>
      <input value={query} onChange={handleChange} />
      {isPending && <Spinner />}
      <ResultsList items={results} />
    </>
  );
}

The isPending flag tells you whether a transition is in progress, so you can show a loading indicator. The input stays responsive while the heavy filtering happens in the background. This is what "concurrent" means in practice: React can work on multiple renders at different priorities and interrupt lower-priority work when higher-priority work arrives.

useDeferredValue

If useTransition wraps the state update, useDeferredValue wraps the value itself. It tells React that a particular value can lag behind the current state. This is useful when you do not control the state update (for example, if a parent component passes a prop that changes frequently).

import { useDeferredValue, useMemo } from 'react';

function ResultsList({ query }) {
  const deferredQuery = useDeferredValue(query);
  const isStale = query !== deferredQuery;

  const filteredItems = useMemo(
    () => filterItems(deferredQuery),
    [deferredQuery]
  );

  return (
    <div style={{ opacity: isStale ? 0.7 : 1 }}>
      {filteredItems.map(item => <Item key={item.id} item={item} />)}
    </div>
  );
}

The deferredQuery value lags behind the actual query. React re-renders the list at a lower priority using the deferred value, keeping the input responsive. You can compare the two values to know if the displayed results are stale and dim the UI accordingly.

Suspense for Data Fetching

Suspense existed in React 16 and 17 for code splitting with React.lazy. React 18 extends it to work with data fetching. The idea is that a component can "suspend" while waiting for data, and React shows a fallback UI until the data is ready.

function ProfilePage({ userId }) {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <ProfileDetails userId={userId} />
    </Suspense>
  );
}

// ProfileDetails can suspend while fetching data
// React will show ProfileSkeleton until data is ready

The power of Suspense is in composition. You can nest Suspense boundaries to control exactly which parts of the UI show loading states independently. A sidebar can load while the main content is still fetching. A comments section can appear after the article body. Each piece of UI has its own loading state without complex state management.

One thing to keep in mind is that React 18 does not ship a built-in data fetching solution that integrates with Suspense. You need a compatible library. Relay, React Query (with experimental Suspense support), and Next.js all have Suspense integrations. The React team has signaled that Suspense-compatible data fetching will become the recommended pattern going forward, but as of right now, the ecosystem is still catching up.

Streaming SSR with renderToPipeableStream

Server-side rendering in React 17 used renderToString, which generated the entire HTML document as a single string in one pass. If any part of the page was slow (a database query, an API call), the entire response was blocked.

React 18 introduces renderToPipeableStream, which streams HTML to the client as it becomes ready. Combined with Suspense boundaries, this means fast parts of the page can be sent immediately while slow parts stream in later.

import { renderToPipeableStream } from 'react-dom/server';

app.get('/', (req, res) => {
  const { pipe } = renderToPipeableStream(<App />, {
    bootstrapScripts: ['/client.js'],
    onShellReady() {
      res.setHeader('Content-Type', 'text/html');
      pipe(res);
    },
  });
});

The "shell" is the HTML that is ready immediately (the layout, navigation, and any non-suspended content). React sends the shell first, and the browser starts rendering it while the server continues processing suspended components. When a suspended component resolves, React streams a small inline script tag that swaps the fallback with the actual content. The user sees meaningful content faster because they do not have to wait for the slowest part of the page.

Selective Hydration

Paired with streaming SSR, React 18 supports selective hydration. In React 17, hydration was all-or-nothing. The entire page had to hydrate before any part of it was interactive. In React 18, Suspense boundaries define independent hydration units. Each boundary can hydrate independently as its JavaScript loads.

Even better, React 18 prioritizes hydration based on user interaction. If a user clicks on a section that has not hydrated yet, React bumps its hydration priority. The section the user is interacting with hydrates first, even if it was scheduled later. This is a huge improvement for large pages with lots of interactive components.

The Mental Model Shift

The core mental model change is that rendering is no longer synchronous and blocking. In React 17, when state changes, React re-renders everything synchronously. Nothing can interrupt it. In React 18, React can pause rendering, work on higher-priority updates, and resume or discard in-progress renders. You do not manage this directly. You express intent through transitions and Suspense boundaries, and React's scheduler handles the rest.

The practical impact is that your React apps can feel more responsive without fundamentally restructuring your code. Migrate to createRoot, wrap expensive state updates in startTransition, add Suspense boundaries around slow-loading sections, and React handles the scheduling. That is a meaningful improvement for a framework upgrade that requires relatively few code changes.

react-18concurrent-modesuspensetransitionsstreaming-ssr