React in Depth
Patterns, Server Components and Testing React
Composition patterns, error boundaries, portals, Server and Client Components in plain words, and testing with Testing Library.
On this page
In one line: a real React app is more than components that work. It needs composition (build big things from small ones with children and props), error boundaries (so one broken widget does not blank the whole page), portals (render a modal outside its parent's box), a clear idea of Server vs Client Components, and tests that act like a user. This lesson runs each of these so you see exactly what they do and do not do.
Up to now you learned how React renders, holds state, fetches data and stays fast. The next question is how to organise code so a team can live with it, how to survive errors, and how to know it still works after the next change.
This is lesson 8 of a ten-lesson series. It leans on hooks and effects from Effects (lesson 2) and Hooks in Depth (lesson 3), and on Suspense from Data Fetching (lesson 5). The next and last lesson, React Interview Questions With Model Answers, collects the questions interviewers ask. For a deeper look at tests in general, see Testing UI Components with Testing Library and Frontend Testing Strategy. For Server Components inside Next.js see Concurrent React, Suspense and Server Components in Next.js.
What I ran / checked. Linux, Node.js v22.22.0, react 19.3.0, react-dom 19.3.0, @testing-library/react 16.3.3, @testing-library/user-event 14.6.7, @testing-library/jest-dom 7.0.1, vitest 5.0.3 with jsdom. All error boundary, portal, pattern and testing outputs below are real runs. I ran renderToString from react-dom/server to show what server rendering produces. Sources I read (own words): React docs for Component (error boundaries section), createRoot (error handler options), createPortal, use, "Server Components", 'use client' and 'use server' (raw markdown from reactjs/react.dev), the Testing Library README for React and its docs pages "Guiding Principles" and "About Queries" (from the testing-library GitHub organisation), and the Next.js docs bundled in this blog's node_modules/next/dist/docs for the claim about Server Components being the default in the App Router. Could not do: I did not run a real React Server Components setup (that needs a framework and bundler). What I show for Server Components is the rules from the docs plus a plain renderToString run, and I say clearly which is which. I did not run a headless browser for the test examples (they run in jsdom). I give no star counts.
Words used in this lesson
- Composition means building a component by putting other components inside it, instead of making one huge component with many options.
childrenis a special prop: whatever JSX you put between a component's opening and closing tags.- A slot is a prop that takes JSX (like
header={<Logo />}), so the parent decides what goes in each part of a layout. - A compound component is a group of components that work together and share state through context, like
Tabs,Tabs.TabandTabs.Panel. - A render prop is a prop whose value is a function that returns JSX, so the caller controls what is rendered.
- A custom hook is a function starting with
usethat reuses stateful logic. - An error boundary is a class component that catches errors thrown while rendering its children and shows a fallback instead.
- A fallback is the UI you show when something is not working or not ready.
- A portal renders children into a different DOM node than their parent, while keeping them in the React tree.
- SSR (server-side rendering) means running React on a server to produce the first HTML of a page. The browser then hydrates it, meaning React attaches event handlers to the existing HTML.
- A Server Component is a component that runs only on the server (or at build time). It is never sent to the browser as code.
- A Client Component is a normal React component that is sent to the browser and can use state, effects and event handlers.
'use client'is a directive at the top of a file that marks it, and everything it imports, as client code.- Serializable means a value that can be turned into plain data (strings, numbers, objects, arrays...) to travel over the network. Functions and class instances are not.
- A Server Function is a function marked
'use server'that the client can call, which becomes a network request to the server. - Testing Library is a family of tools for testing UI the way a user uses it.
actis a React testing helper that makes sure all updates are applied before you check the result.- jsdom is a pretend browser written in JavaScript that tests use to run components without a real browser.
Part 1: composition patterns
React has no inheritance for components. You build by composition: put pieces inside other pieces. The React docs say that every layer of your UI can be a component that gets some pieces of UI as props. Here are the patterns you will see in real code, smallest first.
Pattern 1: children
function Card({ title, children }) { return ( <section className="card"> <h2>{title}</h2> {children} </section> );}<Card title="Profile"> <Avatar /> <p>Hello Ada</p></Card>Card does not know or care what goes inside. It only provides the frame. This is the answer to most "how do I make this reusable?" questions. It also helps performance, as lesson 6 showed: children created by a parent are not re-rendered by the Card's own state changes.
Pattern 2: slots (JSX as props)
When a layout has several regions, use named props:
function Page({ header, sidebar, children }) { return ( <div className="page"> <header>{header}</header> <aside>{sidebar}</aside> <main>{children}</main> </div> );}<Page header={<Logo />} sidebar={<Menu />}> <Article /></Page>This removes prop drilling too. Page does not need to know user to pass it to Menu. The caller builds <Menu user={user} /> once and hands the finished element over.
Pattern 3: compound components
For a widget with several parts that must share state (tabs, accordion, menu), a compound component gives the user a nice API and hides the wiring:
const TabsCtx = createContext(null);function Tabs({ children, defaultValue }) { const [v, setV] = useState(defaultValue); return <TabsCtx value={{ v, setV }}>{children}</TabsCtx>;}Tabs.Tab = ({ value, children }) => { const { v, setV } = useContext(TabsCtx); return <button aria-pressed={v === value} onClick={() => setV(value)}>{children}</button>;};Tabs.Panel = ({ value, children }) => { const { v } = useContext(TabsCtx); return v === value ? <div>{children}</div> : null;};<Tabs defaultValue="a"> <Tabs.Tab value="a">One</Tabs.Tab> <Tabs.Tab value="b">Two</Tabs.Tab> <Tabs.Panel value="a">panel A</Tabs.Panel> <Tabs.Panel value="b">panel B</Tabs.Panel></Tabs>I ran it and clicked "Two":
start: OneTwopanel Aafter click: OneTwopanel BThe user of Tabs writes plain JSX and never touches the state. They can reorder or style the parts freely. The cost: the parts only work inside Tabs (they read its context), and you must give a clear error if used outside.
Pattern 4: render props
A render prop is a prop that is a function returning JSX. The component does a job (like tracking the mouse), and the caller decides how to show it:
function MouseTracker({ render }) { const [pos, setPos] = useState({ x: 0, y: 0 }); return <div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>{render(pos)}</div>;}<MouseTracker render={(pos) => <p>{pos.x}, {pos.y}</p>} />Today, a custom hook (const pos = useMousePosition()) does the same job with less nesting, so render props are rarer. You still see them in libraries when the logic needs to control where something is rendered, such as virtualised lists.
Pattern 5: custom hooks
If two components repeat the same stateful logic, move it into a function that starts with use:
function useOnline() { const [online, setOnline] = useState(navigator.onLine); useEffect(() => { const on = () => setOnline(true), off = () => setOnline(false); window.addEventListener("online", on); window.addEventListener("offline", off); return () => { window.removeEventListener("online", on); window.removeEventListener("offline", off); }; }, []); return online;}Each component that calls useOnline() gets its own state. Hooks share logic, not state. Lesson 3 covers writing them.
Controlled and uncontrolled components
A controlled component gets its value from the parent through props and tells the parent about changes through a callback (value and onChange). An uncontrolled one keeps its own state. Good reusable components often support both: they use the parent's value if given, and their own state if not. The accordion in lesson 6 is a controlled example.
What about HOCs and inheritance?
A higher-order component (HOC) is a function that takes a component and returns a new one (withAuth(Page)). It was popular before hooks. You still meet it in old code and in libraries like react-redux's older connect. For new code, prefer a hook or a wrapper component. Inheritance (class A extends B) between your own components is not how React works. The docs recommend composition.
Trap: do not build one component with 25 boolean props (isBig, isRound, hasIcon, showFooter...). Every prop doubles the cases to think about. Split into smaller components or use children and slots.
Trap (I ran it): never define a component inside another component. The inner function is a new component type on every render, so React throws away its DOM and its state each time. I tried it: I typed "hello" into an input defined inside a parent, then re-rendered the parent, and the input value was "" afterwards. Define components at the top level of the file.
Part 2: error boundaries
By default, if a component throws an error while rendering, React removes the whole UI from the screen. For a real user that is a white page. An error boundary limits the damage: it catches the error from its children and shows a fallback.
React has no function-component version of this yet. The docs say you need a class component with static getDerivedStateFromError (to switch to the fallback) and optionally componentDidCatch (to log). You write it once and reuse it everywhere (or use the react-error-boundary package, which the docs mention).
import { Component } from "react";class ErrorBoundary extends Component { state = { error: null }; static getDerivedStateFromError(error) { return { error }; // next render shows the fallback } componentDidCatch(error, info) { this.props.onError?.(error.message, info.componentStack); // log to a service } render() { return this.state.error ? <p role="alert">Oops: {this.state.error.message}</p> : this.props.children; }}What an error boundary catches, and what it does not
This is a classic interview topic, so I ran each case. Here is the list from the docs, then my proof.
The docs say boundaries do not catch errors in: event handlers, server-side rendering, the boundary itself, and asynchronous code such as setTimeout (with one exception: errors thrown inside the function passed to startTransition).
1 render error -> Oops: render explodedsibling still here | logged: [["render exploded","at Bomb (...patterns.test.jsx:35:17)"]]2 event error -> boom | boundary showed alert? false | window error: [ 'click exploded' ]3 timer error -> t | boundary showed alert? false | window error: []4 startTransition error -> Oops: transition exploded5a before retry -> retryOops: render exploded5b after retry (new key) -> retryfineCase by case:
- Error while rendering: caught. The fallback replaced the broken part, the sibling stayed on the page ("sibling still here"), and
componentDidCatchreceived the message and the component stack (the first line named the component that threw,Bomb). - Error in a click handler: not caught. The boundary shows nothing, and the error went to
windowas an ordinary uncaught error. My button text "boom" stayed on screen. Event handler errors do not break rendering, so React leaves them to you: usetry/catchin the handler and set an error state. - Error in
setTimeout: not caught either. In my test the error did not even reachwindow'serrorevent: jsdom reported it as an uncaught exception to the test runner, which Vitest printed as an "Unhandled Error". In a real browser it would show up in the console and in your error logger. Same fix: handle it yourself. - Error inside
startTransition: caught. The docs call this the exception, and my run confirms it. - Reset: after the fallback shows, the boundary keeps the error in its state. To try again, change the boundary's
key. A new key makes React create a new boundary with fresh state. In my run the "retry" button set the failing flag to false and bumped the key, and the boundary showedfine.
Trap: "Error boundaries catch all errors." No. They catch errors during rendering (including in lifecycle methods and constructors of children), not in event handlers, timers or promises, and not errors thrown by the boundary's own render. For async errors, catch them and put them in state, then throw during render if you want a boundary to handle them. Or use use() with a rejected promise, which a boundary does catch (lesson 5).
Where to place boundaries
- One at the top of the app: the last resort. It shows "Something went wrong, reload".
- Around each independent section: a sidebar, a comments widget, a chart. When the chart crashes, the rest of the page stays alive (as the sibling did in case 1).
- Around each route: so a failing page does not kill the navigation.
Also report errors. React 19 lets you pass onCaughtError, onUncaughtError and onRecoverableError to createRoot, per the createRoot reference. onCaughtError is called for errors a boundary caught, and onUncaughtError for errors nobody caught, both with the error and the component stack. This is a good single place to send errors to your logging service.
Trap: in development, React still shows its red error overlay (in some tools) even when your boundary caught the error. The docs also note that in development errors bubble to window. Check in a production build to see what your users see.
Part 3: portals
Some UI must be drawn on top of everything: modals, tooltips, dropdown menus. If you render a modal deep in the tree, CSS like overflow: hidden, z-index and transform on an ancestor can clip or hide it. A portal renders the children into another place in the DOM, usually directly under <body>:
import { createPortal } from "react-dom";function Modal({ children }) { return createPortal(<div id="modal">{children}</div>, document.body);}The important detail from the docs: a portal only changes where the DOM nodes physically go. In every other way the children behave like normal children in the React tree. They still read the parent's context. And events bubble through the React tree, not the DOM tree. I tested this by wrapping a portal in a div with an onClick:
render( <div onClick={() => log.push("parent div (React ancestor) got click")}> <Modal><button>inside modal</button></Modal> </div>);await userEvent.click(screen.getByText("inside modal"));[ 'parent div (React ancestor) got click' ] | modal DOM parent is <body>, not the app divThe modal's DOM node sits under <body>, yet the click reached the onClick of its React ancestor. This is helpful (a click inside a modal still reaches your component's handlers) and it surprises people: a "click outside to close" handler on the page can fire when you click inside a portal. If that is a problem, stop propagation inside the portal, as the docs say.
For a real modal you also need accessibility: move focus into the dialog when it opens, keep Tab inside, close on Escape and return focus on close. The native HTML <dialog> element handles much of this for you; see the accessibility lesson, Accessibility Essentials Interviewers Check.
Part 4: Server Components and Client Components, in plain words
What is real here and what is not. I could not run a Server Components framework in my sandbox. Everything in this part about Server Components comes from the React docs and the Next.js docs, which I read. The only thing I ran myself is renderToString, to show what plain server rendering (SSR) produces.
Three different things with similar names
People mix these up in interviews, so keep them apart:
| Name | What it is | Where it runs |
|---|---|---|
| CSR (client-side rendering) | JavaScript builds the page in the browser | Browser |
| SSR (server-side rendering) | The server runs React to make the first HTML. The browser then hydrates it | Server once, then browser |
| Server Components | A kind of component that only runs on the server (or at build time). Its code is not sent to the browser | Server |
SSR and Server Components are different and can be used together. SSR makes HTML for all components (even Client Components), and those components then also run in the browser. Server Components never run in the browser.
What SSR gives you (I ran it)
renderToString runs React on the server and returns HTML. Look at what it does with state and effects:
import { createElement as h, useState, useEffect } from "react";import { renderToString } from "react-dom/server";let effectRan = false;function Counter({ start }) { const [n] = useState(start); useEffect(() => { effectRan = true; }, []); return h("button", { onClick: () => {} }, "count ", n);}function Page() { return h("main", null, h("h1", null, "Hello"), h(Counter, { start: 3 })); }console.log(renderToString(h(Page)));console.log("effect ran on the server?", effectRan);<main><h1>Hello</h1><button>count <!-- -->3</button></main>effect ran on the server? falseTwo facts. The output is plain HTML: the button is there, but the onClick is not (an HTML string cannot hold a JavaScript function). And effects do not run on the server. That is why the browser must hydrate: React loads the same components, matches them to the existing HTML, attaches the handlers and runs the effects. If the HTML from the server differs from what the browser renders first (for example, you print Date.now() in render), you get a hydration mismatch warning.
What Server Components add
From the docs: a Server Component renders ahead of time, before bundling, in an environment separate from your client app. It can run once at build time or on each request. Because it runs only there, it can:
- Read from a database or the file system directly (
await db.notes.get(id)), with no API in between. - Use heavy libraries (a markdown parser, a syntax highlighter) that never get sent to the browser. The docs give an example where about 75 kB (gzipped) of libraries disappear from the client bundle.
- Be an
asyncfunction andawaitdata during render. - Avoid the client-side waterfall of "render parent, then fetch, then render child, then fetch".
Server Components cannot:
- Use
useState,useEffector other interactive hooks. They render once and are never re-rendered in the browser. - Use browser APIs (
window,localStorage) or attach event handlers likeonClick. - Create context (they can render a provider imported from a client module).
'use client' draws the line
To add interactivity you mark a file with 'use client' at the very top, above imports. That file and everything it imports become client code. The docs say:
- There is no directive for Server Components. A file with no directive is a Server Component by default in a Server Components setup.
'use server'is not for Server Components. It marks Server Functions, which the client can call. - You put
'use client'only on the boundary file. Files it imports are client code anyway, so you do not repeat it. - A Client Component can receive a Server Component as
children. That is how a server-rendered part appears inside an interactive wrapper. (In the docs' example, anInspirationGeneratorclient component takes aCopyrightserver component aschildren.)
// Notes.jsx (Server Component, no directive)import Expandable from "./Expandable"; // a 'use client' fileexport default async function Notes() { const notes = await db.notes.getAll(); // direct data access, on the server return notes.map((n) => ( <Expandable key={n.id}> <p>{n.text}</p> </Expandable> ));}// Expandable.jsx"use client";import { useState } from "react";export default function Expandable({ children }) { const [open, setOpen] = useState(false); return <div><button onClick={() => setOpen(!open)}>toggle</button>{open && children}</div>;}Props across the line must be serializable
When a Server Component passes props to a Client Component, they travel over the network, so they must be serializable: strings, numbers, booleans, plain objects and arrays, dates, promises, and a few more. The docs list them. Functions (other than Server Functions) and class instances are not allowed, and passing one is an error. This is the most common beginner error: "Functions cannot be passed directly to Client Components".
Server Functions
A function with 'use server' at the top of its body (or file) can be passed to a client and called from it. The call becomes a network request with the arguments serialized, and the return value comes back serialized. They are used for forms and mutations. Treat each one like a public HTTP endpoint: validate its input and check permissions inside, because anyone can call it. See the security series on access control.
The Next.js angle
In the Next.js App Router, the docs I have say that Server Components are used by default (the production checklist states it), and you add "use client" where you need interactivity. A good rule is to keep Client Components as small leaves of the tree: a like button, a search box. Keep layouts and data-heavy parts on the server.
Trap: "Server Components replace SSR" or "'use client' means the component is not server rendered". Both wrong. Client Components are still pre-rendered to HTML on the server in a typical framework (SSR), then hydrated. 'use client' only means the code is also sent to and run in the browser.
Trap: "'use server' marks a Server Component." It does not. There is no directive for Server Components. The docs call this out as a common misunderstanding.
Trap: putting a secret in a Client Component. Anything in a client module is shipped to every user. Keep secrets in server-only code.
Part 5: testing React the way a user uses it
The idea behind Testing Library
The guiding principle of Testing Library, from its docs: the more your tests resemble the way your software is used, the more confidence they can give you. So you do not test "was setState called with 3?" or "does this component have a count state?". You render the component, find things the way a user finds them (by role, label and text), click and type like a user, and check what the user sees.
Why? Because tests on implementation details break when you refactor (rename a state variable) even though nothing changed for the user, and they pass when the UI is broken. Tests on behaviour survive refactoring and fail when the user would be hurt.
The tool set: @testing-library/react (render, screen, queries), @testing-library/user-event (realistic clicks and typing), @testing-library/jest-dom (extra assertions like toBeDisabled), and a runner (Vitest or Jest) with jsdom, which pretends to be a browser.
Query priority
The "About Queries" page gives an order. Prefer, from best to worst:
getByRole(withname): works with how assistive technology sees the page. Use it for almost everything.getByLabelText: for form fields.getByPlaceholderText,getByText,getByDisplayValue.- Then
getByAltText,getByTitle. getByTestId(adata-testidattribute): only when nothing else works.
Querying by role has a bonus: if you cannot find your button by role, probably a screen reader user cannot either. Your test just found an accessibility bug.
Three families of query: getBy... throws if not found (use for things that must be there), queryBy... returns null (use to check something is absent), and findBy... returns a promise that waits (use for things that appear later).
A component and a test that really ran
Here is a small signup form. It checks the email, disables the button while sending, and shows a message on success or failure:
import { useState } from "react";export function SignupForm({ onSubmit }) { const [email, setEmail] = useState(""); const [status, setStatus] = useState("idle"); const [error, setError] = useState(""); async function handle(e) { e.preventDefault(); if (!email.includes("@")) { setError("Please enter a valid email"); return; } setError(""); setStatus("sending"); try { await onSubmit(email); setStatus("done"); } catch { setStatus("idle"); setError("Server said no, try again"); } } return ( <form onSubmit={handle}> <label htmlFor="em">Email</label> <input id="em" value={email} onChange={e => setEmail(e.target.value)} /> <button disabled={status === "sending"}>Sign up</button> {error && <p role="alert">{error}</p>} {status === "done" && <p role="status">Welcome, {email}</p>} </form> );}The tests:
import { render, screen, renderHook, act } from "@testing-library/react";import userEvent from "@testing-library/user-event";import { describe, it, expect, vi } from "vitest";import { SignupForm } from "./SignupForm.jsx";describe("SignupForm", () => { it("shows a message and does not call the server for a bad email", async () => { const onSubmit = vi.fn(); const user = userEvent.setup(); render(<SignupForm onSubmit={onSubmit} />); await user.type(screen.getByLabelText("Email"), "not-an-email"); await user.click(screen.getByRole("button", { name: "Sign up" })); expect(screen.getByRole("alert")).toHaveTextContent("Please enter a valid email"); expect(onSubmit).not.toHaveBeenCalled(); }); it("disables the button while sending, then welcomes the user", async () => { let finish; const onSubmit = vi.fn(() => new Promise((res) => { finish = res; })); const user = userEvent.setup(); render(<SignupForm onSubmit={onSubmit} />); await user.type(screen.getByLabelText("Email"), "ada@example.com"); await user.click(screen.getByRole("button", { name: "Sign up" })); expect(screen.getByRole("button", { name: "Sign up" })).toBeDisabled(); expect(onSubmit).toHaveBeenCalledWith("ada@example.com"); await act(async () => finish()); expect(await screen.findByRole("status")).toHaveTextContent("Welcome, ada@example.com"); }); it("shows an error when the server fails", async () => { const user = userEvent.setup(); render(<SignupForm onSubmit={() => Promise.reject(new Error("500"))} />); await user.type(screen.getByLabelText("Email"), "ada@example.com"); await user.click(screen.getByRole("button", { name: "Sign up" })); expect(await screen.findByRole("alert")).toHaveTextContent("Server said no"); });});Running npx vitest run --reporter=verbose gave:
✓ l8/t/SignupForm.test.jsx > SignupForm > shows a message and does not call the server for a bad email 291ms ✓ l8/t/SignupForm.test.jsx > SignupForm > disables the button while sending, then welcomes the user 114ms ✓ l8/t/SignupForm.test.jsx > SignupForm > shows an error when the server fails 90ms ✓ l8/t/SignupForm.test.jsx > a custom hook > works with renderHook 4ms Test Files 1 passed (1) Tests 4 passed (4)Look at what these tests do not know: the state variable names, the handle function, how many times anything rendered. They know labels, roles and text. I could rewrite the component with useReducer and the tests would still pass. That is the aim.
Notes on the code:
userEvent.setup()thenawait user.type(...)simulates real typing (focus, key presses, input events). The olderfireEventonly dispatches a single event, so preferuserEvent.- The fake
onSubmitis avi.fn()that I control. In the second test I return a promise that I finish by hand, so I can check the "sending" state before it ends. This is the same trick as in lesson 5. await act(async () => finish())makes sure React finishes all updates caused by the resolved promise. Testing Library's own helpers (findBy,waitFor,userEventin the React build) already wrap inactfor you in most cases. You need to callactyourself when something outside those helpers triggers an update, like my manualfinish().findByRolewaits (default up to about a second) until the element appears. UsefindByorwaitForfor async results and neversetTimeoutorsleep.
What a failure looks like
A good tool gives helpful errors. I deliberately searched for a button that does not exist:
screen.getByRole("button", { name: "Submit" });TestingLibraryElementError: Unable to find an accessible element with the role "button" and name "Submit"Here are the accessible roles: textbox: Name "Email": <input id="em" value="" /> -------------------------------------------------- button: Name "Sign up": <button />It lists the roles and names that are available, so you can see your mistake: the button is called "Sign up", not "Submit". Read these messages. They save a lot of time.
Testing a custom hook
For logic that is a hook, renderHook runs it inside a hidden test component:
const useCounter = () => { const [n, set] = useState(0); const inc = useCallback(() => set((c) => c + 1), []); return { n, inc };};const { result } = renderHook(() => useCounter());act(() => result.current.inc());expect(result.current.n).toBe(1);It passed in my run (the fourth line of the report above). Prefer testing hooks through a component when you can, because that is how they are used, and use renderHook for shared hooks with their own logic.
What to mock, and what not
- Do mock the network (
fetch, or a tool like Mock Service Worker that intercepts requests), timers (vi.useFakeTimers), and things jsdom does not have (layout,IntersectionObserver). - Do not mock your own components or child components "to isolate" them. You lose the confidence that the pieces fit. Testing Library's idea is to render the real tree and mock only the edges.
- Do not test React itself or third-party libraries. Test your behaviour.
- Snapshots (
toMatchSnapshot) of big HTML trees break on every small change and people click "update" without reading. Use them rarely and for small, stable output.
What jsdom cannot tell you
jsdom has no layout engine. It cannot tell if a button is hidden behind another one, or if text wraps. It does not do real focus rules perfectly, and it has no scroll or paint. For those, use a real browser test (Playwright) for a few important user journeys. A common healthy split: many fast Testing Library tests for components and a handful of end-to-end tests. The broader picture is in the Testing series.
Trap I met in lesson 5: a component that suspends (for example with use()) inside plain render(...) produced the warning "A component suspended inside an act scope, but the act call was not awaited". The fix is await act(async () => { render(<App />); }).
Trap: getByTestId everywhere. It works, but it does not check that the user can see or reach the thing. Use roles first.
Trap: a test that passes without checking anything: forgetting await before a user.click or a findBy, so the test ends before the assertion runs. If your test is async, await every user action and every findBy.
Try it
- Write a
Cardwithtitleandchildren. Use it twice with different content. Then change it to aPagewithheader,sidebarandchildrenslots. - Build the
Tabscompound component above. Add a test with Testing Library that clicks the second tab and checks that panel B appears and panel A is gone (usequeryByTextfor the absent one). - Write an
ErrorBoundary. Wrap a component that throws on render and check the fallback. Then wrap a button whose click handler throws and see that the fallback does not appear. Fix the second case withtry/catchand an error state. - Add a retry button that resets the boundary using a
key. - Render a modal with
createPortalintodocument.body. Put anonClickon the parentdivand click inside the modal. Does the parent handler fire? Why? - Take the
SignupFormand add a test for "the button is enabled again after a failure". UsegetByRoleonly, no test ids. - Mark each as Server or Client Component and say why: (a) a page that reads a list from a database, (b) a "like" button with a counter, (c) a markdown renderer using a 100 kB library for a static blog post, (d) a search box with
useState.
Answers
- Render the
Cardtwice with different children. For slots, acceptheaderandsidebarprops and place them inside<header>and<aside>. - After
await user.click(screen.getByRole("button", { name: "Two" })), checkscreen.getByText("panel B")is in the document andscreen.queryByText("panel A")isnull. - A render error shows the fallback. A click handler error does not (my case 2: boundary shows nothing, the error goes to
window). In the handler,try { ... } catch (e) { setError(e) }and render the message. - Keep
const [k, setK] = useState(0)in the parent, passkey={k}to the boundary, and increasekin the retry button after making the child healthy again. A new key creates a fresh boundary. - Yes. Events in a portal bubble through the React tree, so the parent's React handler fires even though the DOM node is under
<body>. My result: the parent log line appeared. - After the failing
onSubmit,await screen.findByRole("alert")and thenexpect(screen.getByRole("button", { name: "Sign up" })).toBeEnabled(). - (a) Server, direct data access. (b) Client, state and a click handler. (c) Server, so the library never reaches the browser. (d) Client,
useStateand input events.
Interview questions
What is an error boundary and what does it not catch? (Must know)
An error boundary is a class component with static getDerivedStateFromError (and optionally componentDidCatch) that catches errors thrown while rendering its children and shows a fallback. It does not catch errors in event handlers, in asynchronous code like setTimeout or plain promises, in server-side rendering, or in the boundary itself. I tested these: a render error was caught and the sibling stayed on screen; a click handler error and a timer error were not caught; an error inside startTransition was caught. For events and async code I use try/catch and set an error state.
Where do you put error boundaries? (Senior signal)
At several levels. One at the root as a last resort, one around each route so navigation survives a broken page, and one around independent widgets such as a chart or comments, so only that part is replaced by a fallback. I also give the fallback a retry button that changes the boundary's key so it resets. And I send the errors to a logging service with componentDidCatch or the onCaughtError and onUncaughtError options of createRoot.
What is a portal, and what is surprising about events in a portal? (Must know)
A portal renders children into another DOM node, such as document.body, while keeping them in the same place in the React tree. I use it for modals and tooltips, to avoid clipping from overflow or z-index of ancestors. The surprise is that events bubble through the React tree, not the DOM tree. In my test a click inside the portal reached the onClick of the React parent even though the modal's DOM node was under <body>. Context also works through a portal. I stop propagation when this is unwanted.
What is the difference between SSR and Server Components? (Trap)
SSR renders all components to HTML on the server for a fast first view, and then the browser hydrates them, which means the same components run again in the browser. Server Components are a kind of component that run only on the server or at build time, and their code is never sent to the browser. So SSR is about producing the first HTML, and Server Components are about what code ships and where data is read. They are not alternatives. A Next.js App Router page typically uses both: Server Components by default, with Client Components (also pre-rendered to HTML) for interactive parts.
What does 'use client' do? What does 'use server' do? (Trap)
'use client' at the top of a file marks it and everything it imports as client code, which is sent to the browser and can use state, effects and event handlers. It marks a boundary, so I put it only on the entry file of a client subtree. 'use server' does not mark Server Components. There is no directive for Server Components, since the default is a Server Component. 'use server' marks a Server Function, a function the client can call, which turns into a network request. Because anyone can call it, it must validate input and check permission.
What can a Server Component do that a Client Component cannot, and the other way round? (Must know)
A Server Component can be async, read the database or file system directly, and use large libraries without adding to the client bundle. It cannot use state, effects, event handlers or browser APIs. A Client Component can use all of those, but its code and its dependencies are sent to the user, and it cannot import server-only code. Props passed from server to client must be serializable, so I cannot pass a plain function or a class instance.
How do you test a React component? What is your approach? (Must know)
I use Testing Library with a runner like Vitest or Jest and jsdom. I render the component, find elements the way a user would, with getByRole and getByLabelText first, drive it with userEvent, and assert on what the user sees, with toBeDisabled, toHaveTextContent and similar from jest-dom. I mock the edges like network calls, not child components. I use findBy for async results. I avoid testing state variables or internal function calls, so refactoring does not break my tests. In my signup form example, three behaviour tests passed, and they would still pass if I rewrote the inside of the component.
getBy, queryBy and findBy: what is the difference? (Must know)
getBy returns the element or throws, so I use it for things that must be there. queryBy returns null when nothing is found, so I use it to assert something is absent. findBy returns a promise that retries until the element shows up or a timeout, so I use it for things that appear after async work. There are plural versions (getAllBy...) for many matches.
Why is getByTestId considered a last resort? (Senior signal)
Because it does not say anything about what a user can see or reach. A test id can be on a hidden element, or on a button that has no accessible name. getByRole with a name checks that the element has the right role and label in the accessibility tree, so it tests something real for users of screen readers, and a failing getByRole can point to an accessibility bug. I use test ids only when there is no role, label or text that identifies the element.
When do you need act in a test? (Senior signal)
act makes sure all React updates caused by something are finished before I assert. Testing Library's render, userEvent and findBy already wrap their work in act. I need to call it myself when I trigger an update from outside those helpers, for example resolving a promise by hand, calling a store's setter, or advancing fake timers. Also, when a component suspends during render, I wrap it in await act(async () => { render(...) }). If I forget, React warns that an update was not wrapped in act.
Compound components, render props and custom hooks: when do you use each? (Senior signal)
Compound components when a widget has several parts that share state and the user wants to arrange them, like tabs. Custom hooks when I want to reuse stateful logic and the markup is the caller's job. They are my default now. Render props when the logic must decide where or how often to render something, like a virtualised list. I prefer composition with children before any of these.
Why should components not be defined inside other components? (Trap)
Each render of the outer component creates a new function, which React treats as a new component type, so it unmounts and remounts the inner one and loses its DOM and state. I tested it: after typing "hello" in an input defined inside a parent, a parent re-render reset the input to an empty value. I define components at the top level of the module and pass data through props.
Key takeaways
- Build with composition:
children, slots, compound components, and custom hooks. Avoid 25-prop components, and never define a component inside another. - Error boundaries catch render errors only. In my runs, a render error was caught (sibling survived), a click error and a timer error were not, and an error in
startTransitionwas. Reset with a changingkey. - Portals move the DOM node but not the React position: context works and events bubble to React ancestors.
- SSR makes first HTML (the
renderToStringoutput had no handlers and the effect did not run). Server Components run only on the server and never ship their code.'use client'marks the client boundary.'use server'marks Server Functions, not Server Components. Props across the line must be serializable. - Test like a user:
getByRolefirst,userEvent,findByfor async, assert on what is visible. Mock the edges, not your own components. - jsdom is fast but has no layout: keep a few end-to-end tests for what it cannot see.
What's next
Next: React Interview Questions With Model Answers, the last lesson of the series. Twenty-plus questions from beginner to senior, each with a first-person answer, and the traps that catch people, many of them backed by small runs you saw in this series. To practise now, do exercises 3 and 6 above and write down what each test or boundary actually did.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- Frontend Testing Strategy: Unit, Integration, E2E and What to Skip · Senior Frontend Depth
- Building an Evaluation Harness for an LLM Feature · AI Engineering
- Documenting APIs: OpenAPI, Contracts and Testing · API Design
Part of the study guide
Related series