Bobby Encoded
PostsAbout
PostsAbout

© 2026 Bobby Jose

← Back to Blog

React 18 & 19: Concurrent Features, Suspense, and Server Components

March 21, 2025 · 9 min read

React, React 18, React 19, Server Components, Concurrent React, Interview Prep

Introduction

React 18 and 19 represent the biggest shifts in React's architecture since hooks. Concurrent rendering, Suspense for data fetching, and Server Components fundamentally change how we build React applications. These topics are increasingly common in senior-level interviews.


Concurrent Rendering: The Foundation

Concurrent rendering allows React to interrupt, pause, and resume renders. This enables keeping the UI responsive even during heavy updates.

Why It Matters

Before React 18, renders were synchronous - once started, they blocked until complete. A slow component blocked everything:

// React 17: Blocking render
function App() {
  const [query, setQuery] = useState('');

  return (
    <div>
      <input
        value={query}
        onChange={(e) => setQuery(e.target.value)}  // Triggers render
      />
      <SlowList query={query} />  {/* Blocks input until complete */}
    </div>
  );
}

With concurrent rendering, React can pause SlowList to handle the input update first.


Suspense for Data Fetching

Suspense lets components "wait" for async data with declarative loading states.

Basic Suspense Pattern

import { Suspense } from 'react';

function App() {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<Spinner />}>
        <UserProfile />  {/* Can "suspend" while loading */}
      </Suspense>
      <Suspense fallback={<TableSkeleton />}>
        <DataTable />
      </Suspense>
    </div>
  );
}

Using Suspense with React Query

import { useSuspenseQuery } from '@tanstack/react-query';

function UserProfile({ userId }) {
  // This component "suspends" until data is ready
  const { data: user } = useSuspenseQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId),
  });

  // No loading check needed - data is guaranteed to exist
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// Parent handles loading state
function App() {
  return (
    <Suspense fallback={<ProfileSkeleton />}>
      <UserProfile userId={1} />
    </Suspense>
  );
}

Nested Suspense Boundaries

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

      <div className="content">
        {/* Independent loading states */}
        <Suspense fallback={<SidebarSkeleton />}>
          <Sidebar />
        </Suspense>

        <Suspense fallback={<MainSkeleton />}>
          <MainContent>
            {/* Nested suspense for detailed loading */}
            <Suspense fallback={<ChartSkeleton />}>
              <AnalyticsChart />
            </Suspense>
          </MainContent>
        </Suspense>
      </div>
    </div>
  );
}

Suspense Strategy

Place Suspense boundaries based on user experience, not component structure. Users should see meaningful partial content quickly, not a single loading spinner for the entire page.

Suspense Strategy in Practice

Building Glucoplate, I learned to place Suspense boundaries based on API response times. Fast data shows first, slower calculations load independently. Users see meaningful content quickly instead of waiting for the slowest component. Plan your boundaries around user experience, not component structure.


React Server Components (RSC)

Server Components are React components that render on the server only, sending HTML (not JavaScript) to the client. This is a paradigm shift.

The Mental Model

// Server Component (default in Next.js App Router)
// - Runs on server only
// - Can access database, file system directly
// - Zero client-side JavaScript
// - Cannot use useState, useEffect, or event handlers

async function ProductList() {
  // Direct database query - no API needed!
  const products = await db.products.findMany();

  return (
    <ul>
      {products.map(product => (
        <li key={product.id}>
          <ProductCard product={product} />
        </li>
      ))}
    </ul>
  );
}

Client vs Server Components

// server-component.jsx (default)
// Can: fetch data, access backend, read files
// Cannot: useState, useEffect, onClick, browser APIs

async function ServerComponent() {
  const data = await fetchFromDatabase();
  return <div>{data.title}</div>;
}
// client-component.jsx
'use client';  // This directive makes it a Client Component

import { useState } from 'react';

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

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

Composing Server and Client Components

// ProductPage.jsx (Server Component)
import { AddToCartButton } from './AddToCartButton';  // Client Component

async function ProductPage({ productId }) {
  // Server: fetch product data
  const product = await db.products.findUnique({ where: { id: productId } });

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

      {/* Client Component for interactivity */}
      <AddToCartButton productId={product.id} />
    </div>
  );
}
// AddToCartButton.jsx (Client Component)
'use client';

import { useState } from 'react';
import { addToCart } from './actions';

export function AddToCartButton({ productId }) {
  const [isAdding, setIsAdding] = useState(false);

  const handleClick = async () => {
    setIsAdding(true);
    await addToCart(productId);
    setIsAdding(false);
  };

  return (
    <button onClick={handleClick} disabled={isAdding}>
      {isAdding ? 'Adding...' : 'Add to Cart'}
    </button>
  );
}

When to Use Which

Server ComponentsClient Components
Data fetchingEvent handlers (onClick, onChange)
Backend accessuseState, useEffect
Sensitive logicBrowser APIs
Static contentReal-time updates
Large dependencies (they stay on server)Refs, context consumers

Common RSC Mistake

You cannot import a Server Component into a Client Component. The flow is always Server → Client, never Client → Server. If you need server data in a client component, fetch it in a parent Server Component and pass it as props.


Server Actions

Server Actions let you call server functions directly from client components - no API routes needed.

// actions.js
'use server';

export async function createTodo(formData) {
  const title = formData.get('title');

  await db.todos.create({
    data: { title, completed: false }
  });

  revalidatePath('/todos');
}

export async function deleteTodo(id) {
  await db.todos.delete({ where: { id } });
  revalidatePath('/todos');
}
// TodoForm.jsx (Client Component)
'use client';

import { createTodo } from './actions';

export function TodoForm() {
  return (
    <form action={createTodo}>
      <input name="title" placeholder="New todo..." />
      <button type="submit">Add</button>
    </form>
  );
}

Server Actions with useActionState (React 19)

'use client';

import { useActionState } from 'react';
import { createUser } from './actions';

function SignupForm() {
  const [state, formAction, isPending] = useActionState(createUser, null);

  return (
    <form action={formAction}>
      <input name="email" type="email" />
      <input name="password" type="password" />

      {state?.error && <p className="error">{state.error}</p>}

      <button disabled={isPending}>
        {isPending ? 'Creating...' : 'Sign Up'}
      </button>
    </form>
  );
}

React 19 Features

The React Compiler

React 19 introduces an automatic compiler that handles memoization:

// Before: Manual memoization
function ProductList({ products, onSelect }) {
  const sortedProducts = useMemo(
    () => [...products].sort((a, b) => a.price - b.price),
    [products]
  );

  const handleSelect = useCallback(
    (id) => onSelect(id),
    [onSelect]
  );

  return (
    <ul>
      {sortedProducts.map(product => (
        <MemoizedItem
          key={product.id}
          product={product}
          onSelect={handleSelect}
        />
      ))}
    </ul>
  );
}

// After: React Compiler handles it automatically
function ProductList({ products, onSelect }) {
  const sortedProducts = [...products].sort((a, b) => a.price - b.price);

  return (
    <ul>
      {products.map(product => (
        <Item
          key={product.id}
          product={product}
          onSelect={(id) => onSelect(id)}
        />
      ))}
    </ul>
  );
}
// Compiler automatically memoizes what needs memoizing

Compiler Impact

The React Compiler analyzes your code and automatically inserts memoization. Early reports show 30-60% reduction in unnecessary re-renders without any code changes. However, it doesn't fix algorithmic issues - if your filter is O(n²), it's still slow.

use() Hook

The new use hook can read promises and context:

import { use, Suspense } from 'react';

function Comments({ commentsPromise }) {
  // use() unwraps the promise - suspends until resolved
  const comments = use(commentsPromise);

  return (
    <ul>
      {comments.map(comment => (
        <li key={comment.id}>{comment.text}</li>
      ))}
    </ul>
  );
}

// Parent creates the promise
function Article({ articleId }) {
  const commentsPromise = fetchComments(articleId);

  return (
    <article>
      <Content id={articleId} />
      <Suspense fallback={<CommentsSkeleton />}>
        <Comments commentsPromise={commentsPromise} />
      </Suspense>
    </article>
  );
}

Interview Questions

Q1: What is concurrent rendering and why does it matter?

Answer: Concurrent rendering allows React to interrupt, pause, and resume renders. This matters because:

  1. Responsiveness: Heavy renders don't block user input
  2. Prioritization: Urgent updates (typing) processed before non-urgent (filtering results)
  3. Better UX: No more frozen UIs during expensive operations

It's enabled by default in React 18 for features like useTransition and Suspense.

Q2: Explain the difference between Server and Client Components.

Answer:

Server Components:

  • Render on server, send HTML to client
  • Zero JavaScript bundle impact
  • Can access databases, file systems directly
  • Cannot use hooks, event handlers, or browser APIs

Client Components:

  • Marked with 'use client'
  • Include JavaScript in the bundle
  • Can use useState, useEffect, event handlers
  • Run in the browser

The composition rule: Server Components can render Client Components, but not vice versa.

Q3: When would you use useTransition vs useDeferredValue?

Answer:

  • useTransition: When you control the state update. Wrap setState in startTransition().
  • useDeferredValue: When receiving a value as a prop. Wrap the value itself.
// useTransition - you own the setState
const [isPending, startTransition] = useTransition();
startTransition(() => setSearchResults(filtered));

// useDeferredValue - you receive a prop
const deferredQuery = useDeferredValue(props.query);

Q4: What are the benefits of Server Actions?

Answer:

  1. No API boilerplate: Call server functions directly
  2. Type safety: Full TypeScript support across client/server boundary
  3. Progressive enhancement: Forms work without JavaScript
  4. Automatic revalidation: Easy cache invalidation with revalidatePath
  5. Security: Server-only code never exposed to client

Common Mistakes

1. Using Hooks in Server Components

// ❌ Server Component with useState
async function ProductList() {
  const [sort, setSort] = useState('price');  // Error!
  const products = await db.products.findMany();
  return <ul>...</ul>;
}

// ✅ Make it a Client Component or lift state
'use client';
function ProductList() {
  const [sort, setSort] = useState('price');
  // ...
}

2. Importing Server Components into Client Components

// ❌ Cannot import Server Component into Client
'use client';
import { ServerComponent } from './ServerComponent';  // Error!

// ✅ Pass Server Components as children
'use client';
function ClientWrapper({ children }) {
  return <div className="wrapper">{children}</div>;
}

// In a Server Component:
<ClientWrapper>
  <ServerComponent />  {/* Passed as children - works! */}
</ClientWrapper>

3. Forgetting Suspense Boundaries

// ❌ useSuspenseQuery without Suspense = crash
function App() {
  return <UserProfile />;  // Suspends with no boundary!
}

// ✅ Always wrap suspending components
function App() {
  return (
    <Suspense fallback={<Spinner />}>
      <UserProfile />
    </Suspense>
  );
}

Summary

FeaturePurposeKey Point
Concurrent RenderingInterruptible rendersFoundation for all React 18+ features
useTransitionMark updates as non-urgentKeeps input responsive
useDeferredValueDefer prop updatesFor values you don't control
SuspenseDeclarative loading statesPlace boundaries strategically
Server ComponentsServer-only renderingZero JS, direct backend access
Server ActionsServer functions from clientNo API routes needed
React CompilerAutomatic memoizationComing in React 19

These features represent React's evolution from a client-side library to a full-stack framework. Understanding them is essential for modern React development.


Part 11 of the React Developer Reference series.

← Previous

Mastering SwiftUI Foundations

Next →

TypeScript with React: The Complete Guide