React 18 Deep Dive | Concurrent Features, Suspense, Server Components & Hooks

React 18 Deep Dive | Concurrent Features, Suspense, Server Components & Hooks

이 글의 핵심

React 18 ships concurrent rendering as a first-class feature. This guide covers every new API — useTransition, useDeferredValue, Suspense boundaries, streaming SSR, and Server Components — with real-world patterns you can apply immediately.

What Changed in React 18

React 18 is the biggest release since React 16 (Hooks). The core change is concurrent rendering — React can now work on multiple renders simultaneously, interrupt low-priority work for urgent updates, and stream UI from server to client.

React 17 (synchronous):
  User types → render starts → render blocks main thread → render finishes → UI updates

React 18 (concurrent):
  User types (urgent) → render starts for background work
                      → user input: pause background, handle typing immediately
                      → resume background render
                      → UI updates

New features at a glance:

FeatureWhat it does
useTransitionMark state updates as non-urgent
useDeferredValueDefer expensive re-renders
Suspense (data)Declarative loading states for async data
Streaming SSRSend HTML chunks as they’re ready
Server ComponentsZero-bundle components that run on the server
Automatic batchingBatch state updates everywhere (not just event handlers)

Upgrade

npm install react@18 react-dom@18

# If using TypeScript
npm install -D @types/react@18 @types/react-dom@18
// index.tsx — switch to createRoot
import { createRoot } from 'react-dom/client';
import App from './App';

// Before (React 17)
// ReactDOM.render(<App />, document.getElementById('root'));

// After (React 18)
const root = createRoot(document.getElementById('root')!);
root.render(<App />);

Automatic Batching

React 17 only batched state updates inside React event handlers. React 18 batches everywhere — setTimeout, Promises, native event listeners.

// React 17 — NOT batched (causes 2 renders)
setTimeout(() => {
  setCount(c => c + 1);  // render 1
  setFlag(f => !f);       // render 2
}, 1000);

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

If you need to opt out (rare):

import { flushSync } from 'react-dom';

flushSync(() => {
  setCount(c => c + 1);  // Renders immediately
});
setFlag(f => !f);         // Renders immediately

useTransition — Non-Urgent Updates

useTransition lets you mark state updates as non-urgent. React will keep the UI responsive to higher-priority interactions while processing the transition in the background.

Without useTransition

// Heavy filter — blocks UI while re-rendering
function SearchList() {
  const [query, setQuery] = useState('');
  const results = filterItems(items, query);  // Expensive computation

  return (
    <>
      <input
        value={query}
        onChange={e => setQuery(e.target.value)}  // Blocks on every keystroke
      />
      <List items={results} />
    </>
  );
}

With useTransition

import { useState, useTransition } from 'react';

function SearchList() {
  const [query, setQuery] = useState('');
  const [displayQuery, setDisplayQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
    const value = e.target.value;
    setQuery(value);  // Urgent: update input immediately

    startTransition(() => {
      setDisplayQuery(value);  // Non-urgent: update list in background
    });
  }

  const results = filterItems(items, displayQuery);

  return (
    <>
      <input value={query} onChange={handleChange} />

      {isPending && <Spinner />}  {/* Show while transition is in progress */}

      <List items={results} />
    </>
  );
}

Real-World: Tab Navigation

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

  function selectTab(nextTab: string) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <nav>
        {['home', 'about', 'contact'].map(t => (
          <button
            key={t}
            onClick={() => selectTab(t)}
            style={{ opacity: isPending ? 0.6 : 1 }}  // Visual feedback
          >
            {t}
          </button>
        ))}
      </nav>

      {/* The previous tab stays visible while next tab loads */}
      <Suspense fallback={<Spinner />}>
        <TabContent tab={tab} />
      </Suspense>
    </>
  );
}

useDeferredValue — Defer Expensive Renders

useDeferredValue defers updating a value until more urgent updates have processed. Use it when you receive a value from outside (props or context) that drives an expensive render.

import { useState, useDeferredValue, memo } from 'react';

function SearchPage() {
  const [query, setQuery] = useState('');
  const deferredQuery = useDeferredValue(query);  // Lags behind query

  const isStale = query !== deferredQuery;  // True while update is pending

  return (
    <>
      <input value={query} onChange={e => setQuery(e.target.value)} />

      {/* Results render with deferred value — won't block typing */}
      <div style={{ opacity: isStale ? 0.5 : 1 }}>
        <SearchResults query={deferredQuery} />
      </div>
    </>
  );
}

// Wrap in memo so it only re-renders when deferredQuery changes
const SearchResults = memo(function SearchResults({ query }: { query: string }) {
  const results = expensiveFilter(items, query);
  return <List items={results} />;
});

useTransition vs useDeferredValue:

// useTransition — you control the state update
const [isPending, startTransition] = useTransition();
startTransition(() => setState(newValue));

// useDeferredValue — you receive the value from outside
const deferredValue = useDeferredValue(props.value);

Suspense for Data Fetching

React 18 makes Suspense work for data fetching (not just React.lazy). Components can “suspend” while loading data, and the nearest Suspense boundary shows the fallback.

Basic Pattern

// Framework-integrated data fetching with Suspense
// (Using a Suspense-enabled data fetching library like SWR, React Query, or Next.js)

function UserProfile({ userId }: { userId: number }) {
  const user = useUser(userId);  // Suspends if not yet loaded
  return <div>{user.name}</div>;
}

function App() {
  return (
    <Suspense fallback={<Skeleton />}>
      <UserProfile userId={1} />
    </Suspense>
  );
}

Nested Suspense Boundaries

function Dashboard() {
  return (
    <Suspense fallback={<PageSkeleton />}>
      <Header />  {/* Loads fast */}

      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart />  {/* Independent loading */}
      </Suspense>

      <Suspense fallback={<TableSkeleton />}>
        <OrdersTable />  {/* Independent loading */}
      </Suspense>
    </Suspense>
  );
}
// RevenueChart and OrdersTable load in parallel, each showing their own skeleton

useTransition + Suspense (Keep Previous UI)

function ProfilePage({ userId }: { userId: number }) {
  const [id, setId] = useState(userId);
  const [isPending, startTransition] = useTransition();

  return (
    <>
      <UserList
        onSelect={newId => startTransition(() => setId(newId))}
      />

      {/* While transitioning: old content stays visible (not replaced by fallback) */}
      <Suspense fallback={<Spinner />}>
        <UserProfile userId={id} />
      </Suspense>
    </>
  );
}

Streaming SSR

React 18 enables streaming HTML from the server — the browser receives and renders HTML chunks as they arrive, rather than waiting for the entire page.

// server.ts (Express + React 18)
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);  // Start streaming immediately
      },
      onError(error) {
        console.error(error);
        res.status(500).send('Server Error');
      },
    }
  );
});
// App with Suspense — slow parts stream in later
function App() {
  return (
    <html>
      <body>
        <Nav />  {/* Sent immediately */}

        <Suspense fallback={<ProductSkeleton />}>
          <ProductDetails />  {/* Streamed when ready */}
        </Suspense>

        <Suspense fallback={<ReviewsSkeleton />}>
          <Reviews />  {/* Streamed when ready */}
        </Suspense>
      </body>
    </html>
  );
}

The client receives the shell HTML instantly (fast FCP), then streams in the product and reviews as they load — without waiting for all data before sending anything.


React Server Components

Server Components run exclusively on the server — they have zero impact on the client bundle. Use them for data fetching and database access; use Client Components for interactivity.

Server Components:
  - Run on server only
  - Can access databases, file system, secrets directly
  - Zero bundle size impact
  - Cannot use useState, useEffect, event handlers
  - Async by default

Client Components:
  - Run on client (and pre-rendered on server)
  - Can use all React hooks
  - Can handle user events
  - Add to bundle size

Server Component (default in Next.js App Router)

// app/users/page.tsx — Server Component by default
// No 'use client' directive

async function UsersPage() {
  // Direct database access — no API route needed
  const users = await db.user.findMany({ orderBy: { name: 'asc' } });

  return (
    <main>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </main>
  );
}

export default UsersPage;

Client Component (opt-in with ‘use client’)

'use client';  // This and all imports become client-side

import { useState } from 'react';

export function Counter() {
  const [count, setCount] = useState(0);

  return (
    <button onClick={() => setCount(c => c + 1)}>
      Count: {count}
    </button>
  );
}

Composing Server and Client Components

// app/dashboard/page.tsx — Server Component
async function DashboardPage() {
  // Server-side: fetch data, access DB
  const data = await fetchDashboardData();

  return (
    <div>
      {/* Server Component — passes data as props */}
      <StatsDisplay stats={data.stats} />

      {/* Client Component — handles user interaction */}
      <FilterBar />

      {/* Server Component wrapping a Client Component */}
      <Suspense fallback={<ChartSkeleton />}>
        <RevenueChart data={data.revenue} />
      </Suspense>
    </div>
  );
}
// Server Components can pass serializable data to Client Components
// ❌ Cannot pass: functions, class instances, Promises
// ✅ Can pass: strings, numbers, arrays, plain objects, JSX

Server Actions (Next.js 14+)

// app/actions.ts
'use server';

export async function createUser(formData: FormData) {
  const name = formData.get('name') as string;
  const email = formData.get('email') as string;

  await db.user.create({ data: { name, email } });
  revalidatePath('/users');
}
// app/users/new/page.tsx
import { createUser } from '../actions';

export default function NewUserPage() {
  return (
    <form action={createUser}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <button type="submit">Create</button>
    </form>
  );
}

New Hooks

useId — Stable IDs for SSR

import { useId } from 'react';

function FormField({ label }: { label: string }) {
  const id = useId();  // Stable ID that matches between server and client

  return (
    <div>
      <label htmlFor={id}>{label}</label>
      <input id={id} />
    </div>
  );
}

useSyncExternalStore — Subscribe to External Stores

import { useSyncExternalStore } from 'react';

function useWindowSize() {
  return useSyncExternalStore(
    (callback) => {
      window.addEventListener('resize', callback);
      return () => window.removeEventListener('resize', callback);
    },
    () => ({ width: window.innerWidth, height: window.innerHeight }),
    () => ({ width: 0, height: 0 }),  // Server snapshot
  );
}

function ResponsiveComponent() {
  const { width } = useWindowSize();
  return <div>Width: {width}px</div>;
}

useInsertionEffect — CSS-in-JS Libraries

import { useInsertionEffect } from 'react';

// For CSS-in-JS library authors — fires before any DOM mutations
function useCSS(rule: string) {
  useInsertionEffect(() => {
    if (!styleCache.has(rule)) {
      styleCache.set(rule, true);
      document.head.appendChild(createStylesheet(rule));
    }
  });
}

Performance Patterns

Virtualization for Long Lists

import { useVirtualizer } from '@tanstack/react-virtual';

function VirtualList({ items }: { items: Item[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: items.length,
    getScrollElement: () => parentRef.current,
    estimateSize: () => 50,  // Row height estimate
  });

  return (
    <div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
      <div style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}>
        {virtualizer.getVirtualItems().map(virtualItem => (
          <div
            key={virtualItem.key}
            style={{
              position: 'absolute',
              top: 0,
              transform: `translateY(${virtualItem.start}px)`,
              height: `${virtualItem.size}px`,
            }}
          >
            {items[virtualItem.index].name}
          </div>
        ))}
      </div>
    </div>
  );
}

Avoid Unnecessary Re-renders

// 1. memo — skip re-render if props didn't change
const ExpensiveChart = memo(function ExpensiveChart({ data }: { data: number[] }) {
  return <svg>...</svg>;
});

// 2. useMemo — memoize expensive computations
const sortedItems = useMemo(
  () => [...items].sort((a, b) => a.price - b.price),
  [items]
);

// 3. useCallback — stable function reference for memo'd children
const handleSelect = useCallback((id: number) => {
  setSelectedId(id);
}, []);  // Empty deps = stable reference

// 4. State colocation — keep state close to where it's used
// Don't lift state higher than necessary

Summary

FeatureWhen to use
useTransitionSearch filters, tab navigation, heavy list updates
useDeferredValueDeferred rendering from external values/props
Suspense boundariesParallel data loading, code splitting
Streaming SSRPages with slow data that should show content fast
Server ComponentsData fetching, DB access, large dependencies on server
Client ComponentsuseState, useEffect, event handlers, browser APIs
useIdForm labels, ARIA attributes that need SSR stability

Related posts: