DEV Community

Cover image for React 18: A Complete Guide to Every New Feature
Yogesh Chavan
Yogesh Chavan

Posted on

React 18: A Complete Guide to Every New Feature

React 19 is already here.

But surprisingly, many React developers are still not familiar with the most useful features introduced in React 18.

That's exactly why I created this guide, to help you clearly understand the powerful features React 18 provides and how they improve real-world React applications.

React 18 is the most significant release since React 16 introduced Hooks. At its core, React 18 is about one big idea: concurrent rendering, and everything else flows from it.

This guide walks through every major feature and API introduced in React 18, with practical code examples you can use today.


Table of Contents

  1. Concurrent Rendering
  2. New Root API — createRoot
  3. Automatic Batching
  4. startTransition and useTransition
  5. useDeferredValue
  6. Suspense Improvements
  7. useId
  8. useSyncExternalStore
  9. useInsertionEffect
  10. Strict Mode Changes
  11. React Server Components
  12. Migration Guide

1. Concurrent Rendering

Concurrent Rendering is the flagship feature of React 18, and it changes how React works at a fundamental level. In all previous versions, rendering was synchronous and uninterruptible.

Once React started rendering a component tree, it had to finish before the browser could do anything else. This could make the UI feel sluggish during expensive updates.

With Concurrent Rendering, React gains the ability to pause, resume, and even abandon renders. It can yield to the browser event loop mid-render, keeping the UI responsive even during heavy work. Think of it as React learning how to multitask.

Blocking vs. Concurrent Rendering

Feature React 17 (Blocking) React 18 (Concurrent)
Rendering Synchronous, uninterruptible Can pause and resume
Update priority All updates treated equally Urgent vs. non-urgent
UX during heavy work Can feel janky Stays responsive
Opt-in required N/A Yes, via createRoot()

How It Works

React 18 builds on the Fiber architecture to assign priorities to different updates:

  • High priority: User interactions like clicks and keystrokes
  • Normal priority: Data-driven re-renders
  • Low priority: Background updates wrapped in startTransition

If a high-priority update arrives while a low-priority render is in progress, React can interrupt the low-priority work, handle the urgent update, then resume where it left off.

Note: Concurrent Rendering is completely opt-in. Existing code using ReactDOM.render() continues to work in React 18 without any changes. You only get concurrent features after switching to createRoot().


2. New Root API — createRoot

To unlock concurrent features, you need to migrate from ReactDOM.render() to the new createRoot() API. The old API still works in React 18, but it triggers a deprecation warning and opts you out of everything new.

Before (React 17)

import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>,
  document.getElementById('root')
);
Enter fullscreen mode Exit fullscreen mode

After (React 18)

import { createRoot } from 'react-dom/client';
import App from './App';

const rootElement = document.getElementById('root');
const root = createRoot(rootElement);

root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

The returned root object also exposes root.unmount() to tear down the component tree, replacing the old ReactDOM.unmountComponentAtNode().

hydrateRoot for SSR

If you're using server-side rendering, replace ReactDOM.hydrate() with hydrateRoot():

import { hydrateRoot } from 'react-dom/client';
import App from './App';

const root = hydrateRoot(
  document.getElementById('root'),
  <App />
);

// You can still call root.render() later to update the tree
root.render(<App newProp="value" />);
Enter fullscreen mode Exit fullscreen mode

Tip: In micro-frontend architectures, you can create multiple independent roots on a single page — each is fully isolated and concurrent.


3. Automatic Batching

Batching is when React groups multiple state updates into a single re-render. This has always existed, but until React 17 it only applied inside React event handlers.

Updates inside setTimeout, Promise callbacks, or native event listeners each triggered their own separate re-render.

React 18 fixes this. Now all state updates are batched automatically, regardless of where they originate.

React 17 — Inconsistent Batching

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  // ✅ Batched — 1 re-render (inside React event handler)
  const handleClick = () => {
    setCount(c => c + 1);
    setFlag(f => !f);
  };

  // ❌ NOT batched — 2 separate re-renders in React 17
  const handleAsync = () => {
    setTimeout(() => {
      setCount(c => c + 1); // re-render 1
      setFlag(f => !f);     // re-render 2
    }, 1000);
  };
}
Enter fullscreen mode Exit fullscreen mode

React 18 — Automatic Batching Everywhere

function App() {
  const [count, setCount] = useState(0);
  const [flag, setFlag] = useState(false);

  // ✅ Batched — 1 re-render
  const handleAsync = () => {
    setTimeout(() => {
      setCount(c => c + 1);
      setFlag(f => !f);
    }, 1000);
  };

  // ✅ Also batched inside fetch/promise callbacks
  const fetchData = async () => {
    const data = await fetch('/api/data');
    setCount(c => c + 1); // batched
    setFlag(true);        // batched — single re-render!
  };
}
Enter fullscreen mode Exit fullscreen mode

Opting Out with flushSync

If you need an update to be applied synchronously (for example, to read a DOM measurement immediately after a state change), use flushSync:

import { useState } from "react";
import { flushSync } from 'react-dom';

const handleClick = () => {
 const [count, setCount] = useState(0);
 const [flag, setFlag] = useState(false);

  flushSync(() => {
    setCount(c => c + 1);
  });
  // DOM is updated here before the next line executes

  flushSync(() => {
    setFlag(f => !f);
  });
  // Total: 2 re-renders
};
Enter fullscreen mode Exit fullscreen mode

Tip: Automatic batching is a free performance win. Just upgrading to React 18 with createRoot gives you this — no code changes required. Use flushSync only when you specifically need synchronous DOM updates.


4. startTransition and useTransition

Some state updates are urgent — typing in a search box must feel instant. But the results list it drives might be expensive to render. Before React 18, both updates had equal priority, making the input feel sluggish.

The Transition API lets you tell React: this update is non-urgent — keep the UI responsive and get to it when you can.

startTransition — The Standalone Function

import { startTransition } from 'react';

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

  const handleSearch = (e) => {
    const value = e.target.value;

    // Urgent: update the input immediately
    setQuery(value);

    // Non-urgent: React can interrupt this if the user keeps typing
    startTransition(() => {
      const filtered = expensiveSearch(value);
      setResults(filtered);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleSearch} />
      <ResultsList items={results} />
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useTransition — With a Pending State

The useTransition hook is the recommended approach because it also gives you isPending — a boolean you can use to show a loading indicator while the transition is running.

import { useState, useTransition } from 'react';

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('home');

  const selectTab = (nextTab) => {
    startTransition(() => {
      setTab(nextTab);
    });
  };

  return (
    <div>
      <TabButton isActive={tab === 'home'} onClick={() => selectTab('home')}>
        Home
      </TabButton>
      <TabButton isActive={tab === 'posts'} onClick={() => selectTab('posts')}>
        Posts (slow)
      </TabButton>

      {isPending && <Spinner />}

      <div style={{ opacity: isPending ? 0.7 : 1 }}>
        {tab === 'home' && <HomePage />}
        {tab === 'posts' && <PostsPage />}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Real-World Example: Live Search Over a Large List

import { useState, useTransition, useMemo } from 'react';

const ITEMS = Array.from({ length: 10000 }, (_, i) => ({
  id: i,
  name: `Item ${i}`,
}));

function LiveSearch() {
  const [query, setQuery] = useState('');
  const [deferredQuery, setDeferredQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    const value = e.target.value;
    setQuery(value); // immediate — input stays responsive

    startTransition(() => {
      setDeferredQuery(value); // deferred — filters the big list
    });
  };

  const filtered = useMemo(
    () => ITEMS.filter(item =>
      item.name.toLowerCase().includes(deferredQuery.toLowerCase())
    ),
    [deferredQuery]
  );

  return (
    <div>
      <input value={query} onChange={handleChange} placeholder="Search..." />
      {isPending ? <p>Filtering...</p> : <p>{filtered.length} results</p>}
      <ul>
        {filtered.slice(0, 50).map(item => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Warning: You cannot wrap state updates that control an input's value in a transition. Input values must always be urgent. Only wrap the downstream, results-driven update.


5. useDeferredValue

useDeferredValue is the sibling of useTransition. While useTransition wraps the setter, useDeferredValue wraps the value itself. This makes it ideal when you don't control the state update — for example, when a value comes in as a prop.

import { useState, useDeferredValue } from 'react';

function App() {
  const [query, setQuery] = useState('');

  // deferredQuery lags behind query during concurrent renders
  const deferredQuery = useDeferredValue(query);

  // When query !== deferredQuery, a transition is in progress
  const isStale = query !== deferredQuery;

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}
      />
      {/* Dim results while the deferred value catches up */}
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <SearchResults query={deferredQuery} />
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

useTransition vs. useDeferredValue

useTransition useDeferredValue
What it wraps The state setter The value / prop
isPending available Yes No (check manually with ===)
Best for When you own the state update When value comes from props

6. Suspense Improvements

Suspense has existed since React 16.6, but React 18 dramatically expands what it can do. The key improvements are full support in concurrent mode, Suspense on the server via Streaming SSR, and truly composable loading states across an entire page.

Nested Suspense Boundaries

Each <Suspense> boundary is independent. A component deep in the tree can suspend without affecting siblings that are already loaded.

import { Suspense } from 'react';

function Dashboard() {
  return (
    <div>
      <Suspense fallback={<HeaderSkeleton />}>
        <Header />
      </Suspense>

      <Suspense fallback={<SidebarSkeleton />}>
        <Sidebar />
      </Suspense>

      <Suspense fallback={<MainContentSkeleton />}>
        <MainContent>
          {/* Nested boundaries for fine-grained control */}
          <Suspense fallback={<ChartSkeleton />}>
            <Chart />
          </Suspense>
          <Suspense fallback={<TableSkeleton />}>
            <DataTable />
          </Suspense>
        </MainContent>
      </Suspense>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

SuspenseList — Coordinating Loading Order

SuspenseList lets you control the order in which suspended components reveal themselves, preventing a jarring "popcorn" effect.

import { Suspense, SuspenseList } from 'react';

function ProfilePage() {
  return (
    <SuspenseList revealOrder="forwards" tail="collapsed">
      <Suspense fallback={<AvatarSkeleton />}>
        <ProfileAvatar />
      </Suspense>
      <Suspense fallback={<DetailsSkeleton />}>
        <ProfileDetails />
      </Suspense>
      <Suspense fallback={<PostsSkeleton />}>
        <ProfilePosts />
      </Suspense>
    </SuspenseList>
  );
  // revealOrder: 'forwards' | 'backwards' | 'together'
  // tail: 'collapsed' (show only next skeleton) | 'hidden'
}
Enter fullscreen mode Exit fullscreen mode

Streaming SSR

The biggest SSR improvement in React 18 is HTML streaming. Instead of waiting for all data to be ready before sending any HTML, React can stream the page in chunks:

  • The shell HTML is sent immediately so the browser can start rendering
  • Suspended sections are streamed in as their data resolves
  • Each section hydrates independently — no all-or-nothing hydration
  • Users can interact with loaded parts while others are still loading

Note: Streaming SSR uses renderToPipeableStream (Node.js) or renderToReadableStream (Edge/Web Streams). Frameworks like Next.js 13+ handle this automatically.


7. useId

When building accessible UI, you often need to link a <label> to an <input> using a unique id. Generating these with Math.random() or a counter works on the client but causes hydration mismatches in SSR because the server and client generate different values.

useId solves this by producing stable, collision-free IDs that are consistent across environments.

import { useId } from 'react';

// ❌ Wrong — breaks SSR hydration
function PasswordField() {
  const id = Math.random().toString(); // different on server vs client!
  return (
    <>
      <label htmlFor={id}>Password</label>
      <input id={id} type="password" />
    </>
  );
}

// ✅ Correct — stable and SSR-safe
function PasswordField() {
  const id = useId(); // e.g. ":r0:", ":r1:", etc.
  return (
    <>
      <label htmlFor={id}>Password</label>
      <input id={id} type="password" />
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

Deriving Multiple IDs from One Call

For accessible components that need several linked IDs, derive them from a single useId call with a suffix — this is more efficient than calling useId multiple times.

function FormField({ label, type = 'text', hint }) {
  const id = useId();
  const inputId = `${id}-input`;
  const hintId  = `${id}-hint`;

  return (
    <div>
      <label htmlFor={inputId}>{label}</label>
      <input
        id={inputId}
        type={type}
        aria-describedby={hint ? hintId : undefined}
      />
      {hint && <p id={hintId}>{hint}</p>}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Warning: Do not use useId to generate list keys. Keys should come from your data (e.g. item.id). useId is specifically for accessibility attributes like id, htmlFor, and aria-*.


8. useSyncExternalStore

This hook is primarily for library authors building state management solutions (Redux, Zustand, MobX, etc.). It provides a safe, concurrent-mode-compatible way to subscribe to data stores that live outside React.

The problem it solves is called tearing: in concurrent mode, React can render a component tree partially and then pause.

If your component reads from an external store at different points during that render, it might see two different values — a visual inconsistency. useSyncExternalStore prevents this.

import { useSyncExternalStore } from 'react';

// Subscribing to the browser's online/offline status
function useOnlineStatus() {
  return useSyncExternalStore(
    // subscribe: called when React needs to resubscribe
    (callback) => {
      window.addEventListener('online', callback);
      window.addEventListener('offline', callback);
      return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
      };
    },
    // getSnapshot: returns the current value (client)
    () => navigator.onLine,
    // getServerSnapshot: value to use during SSR
    () => true
  );
}

function StatusBadge() {
  const isOnline = useOnlineStatus();
  return (
    <div className={isOnline ? 'badge-green' : 'badge-red'}>
      {isOnline ? 'Online' : 'Offline'}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Note: If you use Redux Toolkit, Zustand, or Jotai, they already integrate useSyncExternalStore internally. You only need this hook when writing your own state management library or React integration.


9. useInsertionEffect

useInsertionEffect is a specialized hook aimed squarely at CSS-in-JS library authors. It fires synchronously before any DOM mutations, which allows CSS rules to be injected into the DOM before React reads layout in useLayoutEffect.

The execution order is: useInsertionEffectuseLayoutEffectuseEffect.

import { useInsertionEffect } from 'react';

// Inside a CSS-in-JS library — not your application code
function useCSS(rule) {
  useInsertionEffect(() => {
    if (!isInserted(rule)) {
      insertStyleSheet(rule);
    }
  });

  return getClassName(rule);
}
Enter fullscreen mode Exit fullscreen mode

Warning: Unless you are writing a CSS-in-JS library, you should not use useInsertionEffect. For regular side effects use useEffect, and for DOM measurements use useLayoutEffect.


10. Strict Mode Changes

React 18's Strict Mode introduces a new development-only check: it intentionally mounts, unmounts, and remounts every component. This means effects run twice in development:

mount → run effect → cleanup effect → remount → run effect again
Enter fullscreen mode Exit fullscreen mode

This simulates a future React feature (the "offscreen" API) where components can be hidden and reshown — like navigating away from and back to a tab. Your effects need to be resilient to this.

What Breaks and How to Fix It

// ❌ Breaks in Strict Mode — no cleanup, double fetch
useEffect(() => {
  fetch('/api/data').then(r => r.json()).then(setData);
}, []);

// ✅ Fixed — use AbortController for cleanup
useEffect(() => {
  const controller = new AbortController();
  fetch('/api/data', { signal: controller.signal })
    .then(r => r.json())
    .then(setData)
    .catch(err => {
      if (err.name !== 'AbortError') throw err;
    });
  return () => controller.abort();
}, []);

// ❌ Breaks — subscribes without unsubscribing
useEffect(() => {
  const subscription = dataStream.subscribe(handler);
  // Missing cleanup!
}, []);

// ✅ Fixed — always return a cleanup function
useEffect(() => {
  const subscription = dataStream.subscribe(handler);
  return () => subscription.unsubscribe();
}, []);
Enter fullscreen mode Exit fullscreen mode

Tip: If your app behaves oddly in Strict Mode after upgrading to React 18, that's React catching a real bug — missing cleanup in your effects. Fix the cleanup rather than disabling Strict Mode.


11. React Server Components

React Server Components (RSC) are a new paradigm designed alongside React 18's concurrent architecture. They let you write components that render exclusively on the server and are never sent to the client as JavaScript — meaning zero bundle size impact.

While RSC require framework support (Next.js App Router being the primary implementation), understanding them is increasingly essential.

Server vs. Client Components at a Glance

Capability Server Component Client Component
async/await at component level ✅ Yes ❌ No
useState / useEffect ❌ No ✅ Yes
Browser APIs ❌ No ✅ Yes
Direct database / filesystem access ✅ Yes ❌ No
onClick and other event handlers ❌ No ✅ Yes
Bundle size impact 0 bytes Adds to bundle
Context API ❌ No ✅ Yes

Server Component Example

// No directive needed — Server Component by default in Next.js App Router
async function ProductPage({ id }) {
  // Direct database access — no useEffect, no fetch boilerplate
  const product = await db.products.findById(id);
  const reviews = await db.reviews.findByProduct(id);

  return (
    <div>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
      <p>Price: ${product.price}</p>

      {/* Client Components can be composed inside Server Components */}
      <AddToCartButton productId={id} />

      {reviews.map(review => (
        <ReviewCard key={review.id} review={review} />
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Client Component Example

'use client'; // This directive opts the component into the client bundle

import { useState } from 'react';

function AddToCartButton({ productId }) {
  const [added, setAdded] = useState(false);
  const [loading, setLoading] = useState(false);

  const handleAdd = async () => {
    setLoading(true);
    await addToCart(productId);
    setAdded(true);
    setLoading(false);
  };

  if (added) return <p>Added to cart ✓</p>;

  return (
    <button onClick={handleAdd} disabled={loading}>
      {loading ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

The composition model is powerful: Server Components handle data fetching and heavy lifting, while Client Components handle interactivity.

The two types compose naturally — Server Components can render Client Components, passing only serializable props across the boundary.


12. Migration Guide

Migrating from React 17 to React 18 is largely additive. Existing code keeps working, and you can adopt new features incrementally.

Step 1: Update Your Packages

npm install react@18 react-dom@18

# TypeScript users
npm install --save-dev @types/react@18 @types/react-dom@18
Enter fullscreen mode Exit fullscreen mode

Step 2: Switch to the New Root API

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

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

Step 3: Audit Your Effects for Missing Cleanup

With Strict Mode double-invoking effects, any effect that doesn't clean up after itself will surface as a bug. Go through your useEffect calls and ensure every subscription, timer, and fetch has a corresponding cleanup:

// The correct pattern for every useEffect that starts something
useEffect(() => {
  const subscription = subscribe(handler);
  return () => subscription.unsubscribe(); // always clean up
}, [dependency]);
Enter fullscreen mode Exit fullscreen mode

Step 4: Adopt New Features Gradually

Once the basics are in place, adopt new features where they make the most impact:

// Free win: automatic batching requires no changes
// Just upgrading + createRoot gives you this

// High impact for search/filter UIs: useTransition
const [isPending, startTransition] = useTransition();
startTransition(() => setResults(filter(items, query)));

// Useful when value comes from props: useDeferredValue
const deferredQuery = useDeferredValue(searchQuery);

// Accessible forms with SSR: useId
const id = useId();
return <><label htmlFor={id} /><input id={id} /></>;
Enter fullscreen mode Exit fullscreen mode

React 18 API Quick Reference

API Type Purpose
createRoot Function Opt in to concurrent features
hydrateRoot Function SSR hydration with concurrent mode
useTransition Hook Non-urgent state updates with isPending
startTransition Function Mark updates as non-urgent (no isPending)
useDeferredValue Hook Defer re-rendering of non-urgent parts
useId Hook Stable, SSR-safe unique IDs
useSyncExternalStore Hook Safe subscriptions to external stores
useInsertionEffect Hook CSS-in-JS injection (library authors)
flushSync Function Force synchronous state flush
Automatic Batching Behavior Fewer re-renders in async contexts
Suspense (SSR) Feature Streaming HTML from the server

Key Takeaways

  • Upgrade to createRoot first — it unlocks every concurrent feature in this guide.
  • Automatic batching is a free performance improvement that comes with the upgrade alone.
  • Use useTransition and startTransition to keep UIs responsive during expensive updates.
  • Use useDeferredValue when the value driving an expensive render comes from props.
  • Use useId for accessible form relationships in SSR apps, never for list keys.
  • If Strict Mode breaks your app after upgrading, that's a real bug — fix the missing cleanup, don't disable Strict Mode.
  • useSyncExternalStore and useInsertionEffect are for library authors; most application developers won't need them directly.
  • React Server Components are the future, but they require a supporting framework. If you're on Next.js 13+, explore the App Router.

The common thread across all of React 18 is this: the library is getting smarter about when and how it does work, so you can focus on describing what your UI should look like — and React handles the rest.


This article content is taken from my React 18 Features Guide ebook.

If you found this guide useful and want to go deeper, check out The Ultimate React Ebooks Collection — a curated bundle of 20 ebooks and practical guides covering React, Next.js, JavaScript, interview prep, real-world projects, and more.

Everything you need to go from solid fundamentals to a production-ready React developer, in one place.


About Me

I'm a freelancer, mentor, and full-stack developer with 12+ years of experience, working primarily with React, Next.js, and Node.js.

Alongside building real-world web applications, I'm also an Industry/Corporate Trainer, training developers and teams in modern JavaScript, Next.js, and MERN stack technologies with a focus on practical, production-ready skills.

I've also created various courses with 3000+ students enrolled.

My Portfolio: https://yogeshchavan.dev/

Follow me on Linkedin for regular content that I share every day.


Top comments (0)