React 18 launched as a stable release in March, bringing the long-anticipated concurrent rendering engine to production environments. After spending several weeks experiencing it across multiple projects, the performance improvements and developer experience enhancements are visible. What was theoretical in the alpha and beta releases has now proven its worth in real-world applications.

Concurrent Rendering: From Theory to Practice

In my previous coverage of React 18’s alpha release, I explored the shift from “Concurrent Mode” to “Concurrent React” and the theoretical underpinnings of what makes concurrency special. Now that we’re using it in real applications, let’s see how these concepts translate to practical benefits.

A client’s e-commerce product management system previously struggled when administrators filtered through thousands of inventory items. After upgrading to React 18, operations that used to lock up the interface for seconds now remain responsive, all without rewriting the core application logic.

Under the Hood: How Concurrent Rendering Prevents UI Freezing

The key innovation powering this improvement is interruptible rendering. In previous React versions, rendering was synchronous and blocking - once React started rendering a component tree, it had to finish that work before returning control to the browser. During complex updates, this would block the main thread, preventing it from handling user interactions like typing or clicking.

With React 18’s concurrent renderer, rendering work is now broken into small chunks with different priorities:

  1. Time slicing: React performs a bit of rendering work, then pauses and yields back to the browser, allowing it to handle user events, run animations, and maintain responsiveness. If a higher-priority update comes in (like a user typing), React can abandon its current render and prioritize the urgent work.

  2. Prioritized updates: React 18 distinguishes between urgent updates (user interactions) and background transitions (rendering filtered results). This prioritization ensures that your application stays responsive even during complex state updates.

  3. Cooperative scheduling: Rather than monopolizing the main thread, React now cooperatively yields control back to the browser, creating space for user interactions to be processed promptly, even when there’s heavy rendering work happening in the background.

Here’s how this works in practice with the Transition API:

function ProductFilter() {
  const [query, setQuery] = useState('');
  const [filteredProducts, setFilteredProducts] = useState(allProducts);
  const [isPending, startTransition] = useTransition();
  
  function handleFilterChange(e) {
    // This is urgent - show what the user typed immediately
    setQuery(e.target.value);
    
    // This is deferrable - can be interrupted if needed
    startTransition(() => {
      // Even with thousands of products, UI stays responsive
      setFilteredProducts(allProducts.filter(
        product => product.name.includes(e.target.value)
      ));
    });
  }
  
  return (
    <>
      <input value={query} onChange={handleFilterChange} />
      {isPending && <Spinner />}
      <ProductList products={filteredProducts} />
    </>
  );
}

This approach is dramatically different from workarounds we previously relied on like debouncing or throttling, as it doesn’t introduce arbitrary delays - React is intelligent about when to yield control based on the user’s interaction with your app.

Server-Side Rendering Reimagined

One area where React 18 truly shines is its complete overhaul of the server-side rendering architecture through a new feature called “Suspense SSR.”

In previous React versions, SSR was an all-or-nothing affair. Your server would render the entire page to HTML before sending any content to the user. If one component was slow (perhaps fetching data), it would delay the entire page.

React 18 introduces streaming SSR with selective hydration, which fundamentally changes this model:

// Server component using the new SSR model
function App() {
  return (
    <Layout>
      <NavBar />
      <Suspense fallback={<ArticleSkeleton />}>
        <Article />
      </Suspense>
      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>
    </Layout>
  );
}

With this approach, the server sends HTML for the <Layout> and <NavBar> immediately, along with the skeleton placeholders. As <Article> and <Sidebar> become available, the server streams their HTML to the browser. Meanwhile, React on the client can hydrate interactive parts as soon as their HTML arrives, without waiting for the entire page.

New Hooks for the Concurrent World

React 18 introduces several new hooks that help manage the complexities of concurrent rendering:

useId

Update: We are diving deeper in the new post - “React 18’s useId(): The End of Element ID Generation Headaches”

This hook generates unique IDs that are stable across the server and client, solving a longstanding issue with SSR:

function AccessibleInput() {
  const id = useId();
  return (
    <>
      <label htmlFor={id}>Email</label>
      <input id={id} type="email" />
    </>
  );
}

Before useId, we often resorted to problematic workarounds for generating IDs that wouldn’t mismatch between server and client renders. This seemingly small addition has improved our accessibility implementation significantly.

useSyncExternalStore

This hook provides a consistent way to read from external data sources:

const state = useSyncExternalStore(
  subscribe, // How to subscribe to the store
  getSnapshot, // How to get the current value
  getServerSnapshot // Optional: How to get the value during SSR
);

We’ve found this particularly useful when integrating with non-React state management libraries like RxJS. It ensures external stores play nicely with concurrent rendering without showing inconsistent UI states.

useInsertionEffect

This specialized hook runs before any DOM mutations:

useInsertionEffect(() => {
  // Ideal place to inject critical CSS
  const style = document.createElement('style');
  style.innerHTML = '.dynamic-class { color: red }';
  document.head.appendChild(style);
  
  return () => {
    // Clean up
    style.remove();
  };
});

While most developers won’t use this directly, CSS-in-JS library authors are already leveraging it to improve performance and avoid layout thrashing.

Upgrading Stories: What We’ve Learned

Upgrading several production applications to React 18 has taught me some valuable lessons that might help others:

Automatic Batching Reveals Bugs

React 18’s automatic batching is a tremendous performance win, but it can reveal sequencing bugs in your code. In one application, we discovered components that depended on state updates being processed immediately rather than batched. The fix was straightforward - using the new flushSync API in the few cases where we truly need immediate updates:

import { flushSync } from 'react-dom';

function handleClick() {
  flushSync(() => {
    setCounter(c => c + 1);
  });
  // DOM is updated here
  flushSync(() => {
    setFlag(f => !f);
  });
  // DOM is updated again
}

Strict Mode Gets Stricter

React 18’s Strict Mode now double-mounts components to help find effects without proper cleanup. This simulates what will happen with concurrent rendering, where components might mount and unmount multiple times before being visible.

In one dashboard, we discovered several data fetching effects without proper AbortController cleanup:

// Before: Missing cleanup
useEffect(() => {
  fetch('/api/data').then(res => setData(res.json()));
}, []);

// After: Proper cleanup for React 18
useEffect(() => {
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(res => res.json())
    .then(data => setData(data))
    .catch(err => {
      if (err.name !== 'AbortError') {
        setError(err);
      }
    });
  
  return () => controller.abort();
}, []);

Transition API for UX Improvements

The useTransition hook and startTransition function have been game-changers for interactive experiences. For example, in a filtering interface with thousands of items, we previously used debouncing to avoid freezing the UI. With transitions, the code is cleaner and the UX is better:

const [isPending, startTransition] = useTransition();
const [filterText, setFilterText] = useState('');
const [filteredItems, setFilteredItems] = useState(items);

function handleFilterChange(e) {
  // This update is urgent: show what the user typed
  setFilterText(e.target.value);
  
  // This update can be deferred if the system is busy
  startTransition(() => {
    setFilteredItems(
      items.filter(item => 
        item.name.includes(e.target.value)
      )
    );
  });
}

return (
  <>
    <input value={filterText} onChange={handleFilterChange} />
    {isPending ? <Spinner /> : null}
    <ItemList items={filteredItems} />
  </>
);

The isPending state is particularly useful, as it lets us show a subtle loading indicator without replacing the existing content.

Performance in Numbers

Benchmarking the aforementioned client’s e-commerce product catalog before and after upgrading to React 18 revealed some impressive improvements:

  • Time to Interactive (TTI) improved by 32% thanks to streaming SSR
  • Input responsiveness in search filters improved by 45% using transitions

The most significant gain came from eliminating what we call “UI waterfalls” - where one loading state would finish only to trigger another loading state. With Suspense and concurrent rendering, we now show the right placeholder upfront and fill in content progressively.

Upcoming

Now that React 18 is in our production environments, what’s next? The React team has indicated that upcoming work will focus on:

  • React Server Components moving toward stable
  • Further improvements to Suspense, especially for data fetching
  • New compiler optimizations that take advantage of concurrent rendering

For our projects, the immediate focus is on refactoring more parts of our applications to fully leverage transitions and suspense boundaries. The architecture patterns enabled by React 18 reward thoughtful UI decomposition, and we’re just beginning to explore their potential.