Frontend Engineer Hub

React and Component Architecture for Frontend Engineers (2026)

In short

React component architecture in 2026 is Server-Components-first for new code. The mental model: Server Components own data fetching and own the render-tree shape; Client Components own interactivity, state, and browser APIs. Suspense + use() replaces useEffect-with-fetch where the component lifecycle benefits. Custom hooks extract reusable logic; the React Compiler auto-memoizes most components and hooks, eliminating manual useMemo / useCallback boilerplate. The senior bar in 2026: you can articulate the Server / Client boundary, you write idiomatic Suspense / use() code, you compose custom hooks correctly, and you reason about render-tree optimization without the React Compiler crutch.

Key takeaways

  • Server Components changed the React mental model: Server Components are async, render on the server (or at build time), and have direct access to backend resources without a client-side fetch round-trip. Client Components handle interactivity. The React docs (react.dev/reference/rsc/server-components) are the canonical reference.
  • Suspense + use() is the modern React 19 data-fetching pattern when in-render data fetching benefits the lifecycle. The use() hook unwraps a Promise inside a Suspense boundary; the Promise must be stable across renders (cached by key, not recreated).
  • The React Compiler auto-memoizes most components and hooks, eliminating most useMemo / useCallback boilerplate. The compiler released beta with React 19 (December 2024). Senior+ engineers understand what it does (and doesn't) optimize. The React docs (react.dev/learn/react-compiler) are the canonical reference.
  • Custom hooks extract reusable logic; the bar is naming the hook by what it does (useDebounce, useIntersectionObserver, useLocalStorage), not by what it returns. Kent C. Dodds's Epic React (epicreact.dev) covers the canonical extraction patterns.
  • Render-tree optimization is still real even with the React Compiler. The compiler auto-memoizes consecutive renders with stable inputs but cannot reason about expensive children, Context value-change cascades, or list-key invalidation. Senior engineers profile with React DevTools Profiler.
  • The Server Component boundary is at the top of the file: "use client" directive marks a file as Client Component. Imports cascade: a Client Component can render Server Components only via children-prop pattern (the Server Component must be passed as a child, not imported).
  • Server Actions (form action prop, useActionState, useOptimistic) are the React 19 mutation pattern. They eliminate most client-side fetch handlers for form submissions and ship form-validation + optimistic-update + error-handling in a single primitive.

The Server Component / Client Component boundary

The most important architecture decision in modern React is the Server / Client component boundary. The rules:

  • Server Components are the default. A file without "use client" at the top is a Server Component. Server Components render on the server; they can be async and have direct access to backend resources.
  • Client Components opt in. Add "use client" at the top of a file to mark it (and everything imported from it) as a Client Component. Client Components handle state (useState), effects (useEffect), and browser APIs.
  • Server Components can render Client Components directly. A Server Component imports a Client Component and renders it like any other React component.
  • Client Components render Server Components only via children-prop. A Client Component cannot import a Server Component directly (the bundler would inline the Server Component into the client bundle, breaking the boundary). Instead, the Server Component is passed as a children prop from a parent Server Component.

A canonical example — a sidebar layout where the sidebar is interactive (Client Component) but the main content is server-rendered (Server Component):

// app/dashboard/layout.tsx — Server Component
import { Sidebar } from "./sidebar";
import { ServerStats } from "./server-stats";

export default function DashboardLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <div className="dashboard">
      <Sidebar>
        {/* ServerStats is a Server Component passed as children to the
          * Client Component. The Client Component renders it without
          * needing to import it directly. */}
        <ServerStats />
      </Sidebar>
      <main>{children}</main>
    </div>
  );
}
// app/dashboard/sidebar.tsx — Client Component
"use client";

import { useState } from "react";

type Props = {
  children: React.ReactNode;
};

export function Sidebar({ children }: Props) {
  const [collapsed, setCollapsed] = useState(false);

  return (
    <aside
      data-collapsed={collapsed ? "true" : "false"}
      aria-expanded={collapsed ? "false" : "true"}
    >
      <button
        type="button"
        onClick={() => setCollapsed((c) => !c)}
        aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
      >
        {collapsed ? "→" : "←"}
      </button>
      {!collapsed && children}
    </aside>
  );
}

The key pattern: the Sidebar Client Component renders the ServerStats Server Component via the children prop, never via direct import. This is the children-prop escape hatch that lets Client Components compose with Server Components without breaking the bundle boundary. The Next.js docs (nextjs.org/docs/app/getting-started/server-and-client-components) cover this in depth.

Suspense + use() for in-render data fetching

The React 19 use() hook unwraps a Promise inside a Suspense boundary. It is the modern replacement for useEffect-with-fetch when the component lifecycle benefits from suspending the render until data is ready.

The pattern looks like this:

import { Suspense, use } from "react";

// Stable promise factory — the promise must NOT be recreated on every render.
// Cache by stable key so React reads a stable Usable across renders.
const userCache = new Map<string, Promise<User>>();

function getUserPromise(userId: string): Promise<User> {
  let promise = userCache.get(userId);
  if (!promise) {
    promise = fetch(\`/api/users/${userId}\`).then((res) => res.json());
    userCache.set(userId, promise);
  }
  return promise;
}

// Component that uses the promise via use()
function UserProfile({ userId }: { userId: string }) {
  // use() unwraps the promise; throws to the nearest Suspense boundary
  // until the promise resolves.
  const user: User = use(getUserPromise(userId));
  return (
    <article>
      <h1>{user.name}</h1>
      <p>{user.bio}</p>
    </article>
  );
}

// Parent renders inside a Suspense boundary that owns the loading state.
export function UserProfilePage({ userId }: { userId: string }) {
  return (
    <Suspense fallback={<div>Loading\u2026</div>}>
      <UserProfile userId={userId} />
    </Suspense>
  );
}

What this code gets right: the promise is cached by stable key (userId), so React reads a stable Usable across renders; the Suspense boundary is in the parent so the loading state is owned at the right level; the component is a true async boundary (any descendant that uses() will suspend until its data is ready).

What this code does NOT get right: there is no error boundary. In production, every Suspense boundary should be paired with an error boundary so a rejected promise renders a sensible error UI rather than crashing the page. The React docs (react.dev/reference/react/Suspense) cover the pattern.

When NOT to use Suspense + use(): for data that must mutate-then-refetch (use TanStack Query or SWR), for data that is part of an event handler's side effect (use a regular fetch in the handler), or for data that is purely client-side state. The Suspense + use() pattern is for in-render data fetching only — not the universal data-fetching abstraction.

Custom hooks: extracting reusable logic

Custom hooks extract reusable React logic into a named function. The convention: hook names start with use; they can call other hooks; they encapsulate state, effects, and refs as needed. Below is a canonical custom hook — useIntersectionObserver, the kind of utility every senior frontend engineer writes once and reuses across projects:

import { useEffect, useRef, useState, type RefObject } from "react";

type UseIntersectionObserverOptions = {
  /** Margin around the root. Same shape as IntersectionObserver rootMargin. */
  rootMargin?: string;
  /** Threshold(s) at which to trigger. */
  threshold?: number | number[];
  /** If true, disconnect the observer once the element first intersects. */
  freezeOnceVisible?: boolean;
};

export function useIntersectionObserver<T extends HTMLElement>(
  options: UseIntersectionObserverOptions = {},
): {
  ref: RefObject<T>;
  isIntersecting: boolean;
  entry: IntersectionObserverEntry | null;
} {
  const { rootMargin = "0px", threshold = 0, freezeOnceVisible = false } = options;
  const ref = useRef<T>(null);
  const [entry, setEntry] = useState<IntersectionObserverEntry | null>(null);
  const frozenRef = useRef(false);

  useEffect(() => {
    const node = ref.current;
    if (!node) return;
    if (typeof IntersectionObserver === "undefined") return;
    if (frozenRef.current) return;

    const observer = new IntersectionObserver(
      ([nextEntry]) => {
        setEntry(nextEntry);
        if (freezeOnceVisible && nextEntry.isIntersecting) {
          frozenRef.current = true;
          observer.disconnect();
        }
      },
      { rootMargin, threshold },
    );

    observer.observe(node);
    return () => observer.disconnect();
  }, [rootMargin, JSON.stringify(threshold), freezeOnceVisible]);

  return {
    ref,
    isIntersecting: entry?.isIntersecting ?? false,
    entry,
  };
}

What this hook gets right: typed ref generic over the element type; defaults applied at destructuring; environment guard for SSR (typeof IntersectionObserver === "undefined"); freeze-once-visible flag for lazy-load-once patterns; observer.disconnect cleanup; threshold serialized for stable dependency.

What this hook does NOT get right: the JSON.stringify(threshold) dependency is a known anti-pattern that works in practice but is brittle — a more rigorous approach would memoize the threshold via useMemo with a custom comparator. Senior+ engineers know the trade-off and pick the simpler version when the threshold is rarely complex.

The Kent C. Dodds Epic React content (epicreact.dev) is the canonical reference for custom-hook extraction patterns.

The React Compiler: what it does and doesn't optimize

The React Compiler released beta with React 19 (December 2024) and stabilized through 2025. The compiler runs at build time, analyzes React component code, and inserts memoization where it would be beneficial — eliminating most manual useMemo / useCallback boilerplate.

What the compiler optimizes:

  • Re-render avoidance for components with stable props. The compiler memoizes child components when their props haven't changed.
  • Stable callback references. The compiler memoizes inline arrow functions in JSX so the function reference stays stable across renders.
  • Memoization of derived values. The compiler memoizes computed values where the inputs are stable.

What the compiler does NOT optimize:

  • Server Components. The compiler operates on Client Components; Server Components are server-side and don't benefit from client-side memoization.
  • Context value cascades. When a Context value changes, every consumer re-renders. The compiler can't fix this; the engineer must split the context or memoize the value.
  • Expensive children that depend on changing props. If the children's prop genuinely changes every render, no memoization helps; the engineer must restructure the component.
  • List-key invalidation. The compiler can't fix wrong key props; the engineer must pick stable keys.

The senior+ bar in 2026: you've turned the compiler on in at least one project, you understand what it does, and you don't manually memoize routine work. You still profile with React DevTools when performance regressions arise; the compiler is leverage, not magic. The React docs (react.dev/learn/react-compiler) are the canonical reference.

Server Actions: the modern mutation pattern

Server Actions are the React 19 mutation pattern. They are async functions marked with "use server" that run on the server when called from a Client Component (typically via a form submission or button click). Server Actions integrate with React 19 hooks (useActionState, useOptimistic, useFormStatus) for form-validation, optimistic updates, and progressive enhancement.

A canonical pattern — a comment-form that uses a Server Action with optimistic UI:

// app/actions/post-comment.ts — Server Action module
"use server";

import { z } from "zod";
import { revalidatePath } from "next/cache";
import { db } from "@/lib/db";
import { getCurrentUser } from "@/lib/auth";

const CommentSchema = z.object({
  postId: z.string().uuid(),
  body: z.string().min(1).max(2000),
});

type State = {
  ok: boolean;
  error: string | null;
};

export async function postComment(
  _prevState: State,
  formData: FormData,
): Promise<State> {
  const user = await getCurrentUser();
  if (!user) {
    return { ok: false, error: "Not authenticated" };
  }

  const parsed = CommentSchema.safeParse({
    postId: formData.get("postId"),
    body: formData.get("body"),
  });

  if (!parsed.success) {
    return { ok: false, error: "Invalid input" };
  }

  await db.comment.insert({
    postId: parsed.data.postId,
    body: parsed.data.body,
    authorId: user.id,
  });

  revalidatePath(\`/posts/${parsed.data.postId}\`);
  return { ok: true, error: null };
}
// app/posts/[postId]/comment-form.tsx — Client Component
"use client";

import { useActionState, useOptimistic } from "react";
import { postComment } from "@/app/actions/post-comment";

type Comment = { id: string; body: string; authorName: string };

export function CommentForm({
  postId,
  comments,
}: {
  postId: string;
  comments: Comment[];
}) {
  const [state, formAction, isPending] = useActionState(postComment, {
    ok: true,
    error: null,
  });

  const [optimisticComments, addOptimistic] = useOptimistic(
    comments,
    (current, body: string) => [
      ...current,
      { id: \`temp-${Date.now()}\`, body, authorName: "You" },
    ],
  );

  return (
    <>
      <ul>
        {optimisticComments.map((c) => (
          <li key={c.id}>
            <strong>{c.authorName}:</strong> {c.body}
          </li>
        ))}
      </ul>
      <form
        action={async (formData) => {
          const body = formData.get("body") as string;
          addOptimistic(body);
          await formAction(formData);
        }}
      >
        <input type="hidden" name="postId" value={postId} />
        <label htmlFor="body">Comment</label>
        <textarea id="body" name="body" required maxLength={2000} />
        {state.error && <p role="alert">{state.error}</p>}
        <button type="submit" disabled={isPending}>
          {isPending ? "Posting\u2026" : "Post comment"}
        </button>
      </form>
    </>
  );
}

What this code gets right: server-side validation with zod (never trust the client); revalidatePath to refresh the cached page after mutation; useActionState integration for the form submit; useOptimistic for the optimistic comment append; proper accessibility (label, aria-invalid via the form action, role="alert" for error message).

The Server Actions pattern is the modern React mutation primitive. It eliminates the need for most client-side fetch handlers and integrates form-validation + optimistic-update + error-handling in a single primitive. The Next.js docs (nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations) and the React docs (react.dev/reference/react/useActionState) are the canonical references.

Frequently asked questions

Should every component be a Server Component?
No. Server Components handle data fetching and render-tree shape; Client Components handle interactivity (state, effects, browser APIs). The senior bar: a leaf component that uses useState becomes a Client Component; an ancestor that fetches data and composes children stays a Server Component. The right pattern in modern Next.js is Server-Components-by-default with Client islands at the smallest possible scope.
When should I use Suspense + use() vs TanStack Query?
Suspense + use() for in-render data fetching where the component lifecycle benefits — the data is part of the render. TanStack Query for client-side data with mutation-then-refetch semantics, optimistic updates managed by the library, or background-refetch behavior. The two patterns coexist: Suspense + use() in Server Components and at the route shell; TanStack Query inside Client Components for interactive data.
Do I still need useMemo and useCallback with the React Compiler?
Rarely. The React Compiler (React 19+, stable in 2025) auto-memoizes most components and hooks, eliminating most manual useMemo / useCallback boilerplate. Manual memoization is still useful for: extremely expensive computations the compiler can't reason about; explicit-stable-reference contracts (e.g., a callback passed to a useEffect dependency array); cases where the compiler hasn't been turned on. The React docs (react.dev/learn/react-compiler) cover the trade-offs.
How do I handle errors in Suspense boundaries?
Pair every Suspense boundary with an error boundary. React's error-boundary component (or a third-party like react-error-boundary) catches thrown errors during render. The pattern: .... In Next.js App Router, route-level error.tsx files provide error boundaries automatically.
What's the children-prop pattern and why does it matter?
Client Components cannot import Server Components directly (the bundler would inline them, breaking the boundary). The escape hatch is the children prop: pass the Server Component as children to the Client Component from a parent Server Component. This pattern lets you have an interactive shell (Client Component) with server-rendered content inside it, without bundling the server-only code into the client bundle. The Next.js docs cover this with the term 'composition pattern'.
How do I structure a large React app's component hierarchy?
Modern 2026 pattern: route-level Server Components that fetch data and own the page shape; smaller Client Component islands for interactivity; shared design-system primitives (Button, Input, Modal) imported from a package; custom hooks for reusable logic; route-level error and loading boundaries. The colocation principle (Kent C. Dodds): keep code close to where it's used; prefer composition over inheritance.
Should I use class components in 2026?
Only for Error Boundaries (the only React API that requires a class component). Everything else is functional components with hooks. The React class-component API is still supported but is not recommended for new code.

Sources

  1. React.dev — Server Components reference. Canonical for the Server / Client boundary.
  2. React.dev — the use() hook reference. Canonical for Suspense + use() data fetching.
  3. React.dev — React Compiler documentation. Canonical for compiler tradeoffs.
  4. Next.js Docs — Server and Client Components. Canonical for the pattern in production.
  5. Next.js Docs — Server Actions and Mutations. Canonical for React 19 mutation patterns.
  6. Kent C. Dodds — Epic React. Canonical custom-hook and component-architecture course.
  7. Dan Abramov — overreacted.io. Canonical writing on React internals (useEffect, suspense, server components).

About the author. Blake Crosley founded ResumeGeni and writes about frontend engineering, hiring technology, and ATS optimization. More writing at blakecrosley.com.