The idea still holds (keep rendering apart from data and side effects), but the split now usually sits between a custom hook and a component that renders, not between two component layers. Dan Abramov, who popularised the pattern, stopped recommending it as a rule in 2019.
What it was: a container fetched data and held state; a presentational component got everything through props and rendered. The UI became reusable and easy to test with fake data.
What changed: hooks let any component call useQuery or a store hook directly, so a wrapper whose only job is passing props down adds a layer without adding value.
Where the split still pays: design system components (props in, events out), components shown in Storybook with fake data, views fed by two different sources.
Modern form: useOrders() holds fetching and mutations, OrdersTable takes orders and onCancel as props, and the page wires them together.
Server Components bring a version of it back: a server component fetches, a client component handles interaction.
ReactSenior
Patterns & Architecture
Composition patterns, compound components, render props and HOCs, error boundaries, portals, folder structure, design systems, monorepos
Questions closed
0 / 20
- 0Review
- 6Asked often
Questions
SOLID was written for classes, but each rule has a React form: one reason to change per component or hook, extension through composition instead of new flags, wrappers that keep the contract of what they wrap, narrow props, and dependencies passed in rather than imported.
S, single responsibility: a component that fetches, transforms and renders has three reasons to change. Move data and effects into a custom hook (useUser) and keep the component about markup.
O, open/closed: extend a component through children, slots and render props instead of adding showIcon, isCompact and variant2 for every new case.
L, Liskov substitution: a Button that wraps <button> accepts all of its props (ComponentProps<'button'>), spreads the rest and passes ref through, so it can replace a native button anywhere.
I, interface segregation: pass name and avatarUrl, not the whole user: a memo on the component then ignores changes to fields it never reads.
D, dependency inversion: components get callbacks and services from context instead of importing a concrete API module, so tests and Storybook can swap them.
Limit: applied mechanically, the rules give dozens of three-line components; split when a part changes for a different reason, not by line count.
React has no DI container: the injection points are props, context and the module system. For a service such as an API client, define an interface, provide the real implementation through a context at the root and a fake one in tests and stories, and let components read it with a useApi() hook.
Props: the most explicit form, fine for a callback or a service one level down, noisy once every layer forwards it.
Context: the usual choice for app-wide services (API client, analytics, feature flags, a clock). Create the service once, at module level or with useState(() => createApi()), so the provider value never changes and never re-renders consumers.
Module mocking (vi.mock('./api')): no extra code in the app, but it is global to the test file, tied to the import path and unavailable in Storybook.
Network level: for plain HTTP, MSW often makes injection unnecessary: the real client runs and only the responses are fake. Inject when the dependency is not HTTP (a WebSocket, a payment SDK, the current time) or when the client has logic of its own to replace.
Throw from useApi() outside the provider instead of falling back to the real client, or a test without the provider silently calls the real backend.
First restructure: pass ready-made elements through children or slot props, so intermediate components stop forwarding props they never use. Use context for values many distant components read, and a store only for app-wide state that changes often.
Composition first: with <Layout sidebar={<UserMenu user={user} />}> the Layout never sees user. This often removes the drilling altogether.
Colocate state: state used by one subtree lives at the top of that subtree, not in the app root.
Context for theme, locale, the current user, a form's state: values read in many places and changed rarely. Every consumer re-renders when the value changes, so split contexts by how often they change.
A store (Zustand, Redux Toolkit) when many unrelated parts read and write the same changing data and need selectors.
Passing a prop two or three levels down is fine: it is explicit and easy to trace.
Compound components are a set of components that only work together and share implicit state through a context the parent provides, like <Tabs>, <Tabs.Tab> and <Tabs.Panel>. The caller arranges the markup freely while the parent owns the state.
Why: instead of one component with a tabs={[...]} config prop and a dozen render options, the caller places the pieces, wraps them and adds custom content.
How: the parent holds state and provides it with createContext; each child reads it with use(Context) or useContext. In React 19 the context itself renders as the provider.
Guard: a child used outside its parent throws a clear error instead of silently reading a default value.
Accessibility: the parent also generates ids with useId to link aria-controls and aria-labelledby.
Radix UI, React Aria and Headless UI are built this way. The older React.Children.map plus cloneElement approach breaks as soon as a child is wrapped in a fragment or another component.
A render prop is a function prop that a component calls to find out what to render, passing its internal state as arguments, like renderItem={(item) => ...}. Custom hooks replaced it for sharing logic, but it is still right when the component owns the markup and the caller customises one part.
History: before hooks, <Mouse>{({ x, y }) => ...}</Mouse> was how stateful logic was shared, at the cost of deep nesting.
Logic only: a custom hook such as useMousePosition() is simpler, adds no component to the tree and composes without nesting.
Still useful: renderItem in a virtualised list, renderOption in a combobox, a table cell renderer. The component controls DOM structure, focus and keyboard handling; the caller controls one slot.
Cost: an inline function is a new value on each render, so a memo child re-renders. React Compiler memoizes it for you; without the compiler use useCallback where it matters.
A higher-order component (HOC) is a function that takes a component and returns a new one with extra props or behaviour, like withAuth(Page) or the old Redux connect. Hooks replaced most HOCs because HOCs hide where props come from and stack into deep wrapper trees.
Problems: prop name collisions between two HOCs; it is unclear which wrapper injected a prop; refs and static members need manual forwarding; typing injected props in TypeScript is awkward; every HOC adds a level in DevTools.
Rules if you write one: create it at module level, never inside render; pass unrelated props through with {...props}; set displayName so DevTools shows withAuth(Settings).
Where they still fit: cross-cutting wrappers that render around a component regardless of its logic: an auth gate, an error boundary (withErrorBoundary), feature flags. memo and MobX's observer are HOCs too.
Instead: a custom hook for logic (useAuth()), a wrapper component for layout.
A headless component or library ships behaviour without markup or styles: state, keyboard handling, focus management and ARIA wiring, while you render and style every element. You get accessibility that is hard to get right yourself and keep full control of the design.
Two forms: hooks that return state and prop getters (TanStack Table's useReactTable, React Aria's useButton, Downshift), or unstyled components (Radix Primitives, React Aria Components, Headless UI, Base UI) that render minimal DOM and expose state as attributes such as data-state="open" for styling.
What you get: the WAI-ARIA patterns for combobox, menu, dialog and tabs, tested with screen readers: roving focus, typeahead, Esc and focus return.
Why not a styled kit (MUI, Ant Design): restyling someone else's design system means fighting its CSS and specificity; a headless base takes your own tokens from the start.
Costs: you write every style, assemble each widget and keep the app consistent yourself. Teams therefore wrap the primitives in their own design system components; shadcn/ui does the same by copying Radix-based components, styled with Tailwind, into your repo.
An error boundary catches errors thrown while rendering its subtree (and in lifecycle methods) and shows a fallback instead of unmounting the whole app. It does not catch errors in event handlers, in async code, during server rendering or in the boundary itself.
Still a class: only a class with static getDerivedStateFromError (switch to the fallback) and/or componentDidCatch (log) can be a boundary; there is no hook for it. Most teams use react-error-boundary, which wraps that class.
Why it exists: since React 16 an uncaught render error unmounts the whole root, because a blank page is safer than a corrupted UI. A boundary limits the damage to one widget.
Not caught: event handlers run outside rendering, so use try/catch there. For async errors, store the error in state and throw it during render, or call showBoundary from react-error-boundary.
React 19: an error thrown inside a startTransition callback or a form Action does reach the nearest boundary, and createRoot accepts onCaughtError and onUncaughtError for reporting.
Error boundaries catch errors thrown during rendering, and an event handler (let alone a promise that rejects later) runs outside rendering, so the rejection goes to unhandledrejection and the UI carries on as if nothing happened.
React calls a boundary's getDerivedStateFromError only when a component throws while React renders or commits it. handleClick runs later, from a browser event, and its rejected promise is not connected to the tree.
Fix 1: catch the error and show it next to the button. This is usually better UX than replacing the whole widget with a fallback.
Fix 2: if the failure should take the section down, rethrow it during render: keep it in state and throw it, as in the fixed code, or call showBoundary(error) from useErrorBoundary in react-error-boundary.
Fix 3 (React 19): run the save inside startTransition; an error thrown in a transition's async function goes to the nearest boundary.
createPortal(children, domNode) renders children into a different DOM node (usually at the end of document.body) while they keep their place in the React tree. You need it for modals, tooltips, popovers and toasts that must escape a parent's overflow: hidden or stacking context.
What stays with the React tree: context, state and event bubbling follow the React parent, not the DOM parent. A modal inside a ThemeProvider still gets the theme.
**Why position: fixed is not enough**: an ancestor with transform or filter makes fixed relative to itself, overflow: hidden clips, and no z-index escapes a parent's stacking context.
Native alternative: <dialog> opened with showModal() and the Popover API render in the browser's top layer, above everything without a portal, and handle Esc and focus.
Accessibility: a portal only moves DOM. Focus trapping, aria-modal, returning focus and inert on the background are still your job or your library's.
SSR: document does not exist on the server, so create the portal after the component mounts.
It logs button, then App div: React propagates its events along the React tree, and there the portal content is a child of the div, even though in the DOM the button sits directly in document.body.
React listens at the root container (and at every portal container) and dispatches its synthetic events by walking the component tree, so a portal behaves like any other child for onClick, onKeyDown, onFocus and the rest.
A native addEventListener('click') on that DOM div would not fire, because in the DOM the button is not inside it.
This is why a click inside a modal can trigger an outer onClick that, say, closes a dropdown. Stop it with e.stopPropagation() at the portal's root, or compare e.target with the container.
Context follows the same rule: the portal reads providers from its React ancestors.
Group code by feature, not by file type: features/checkout holds its components, hooks, API calls and tests together, while shared/ holds generic UI and utilities that know nothing about any feature. A change then touches one folder, and deleting a feature means deleting one folder.
By type (components/, hooks/, services/) is fine for a small app, but at a few hundred files one feature is spread over five folders and nobody knows what is safe to remove.
Layers: app/ (providers, router, global styles), pages/ or routes/ (thin, they compose features), features/ (business logic and its UI), shared/ (design system, API client, helpers).
Dependency direction: pages import features, features import shared, never the other way; a feature does not reach into another feature's internals.
Public API: each feature exposes an index.ts; everything else in the folder is private.
Feature-Sliced Design formalises this with fixed layers and import rules; adopt it when the team wants a strict shared standard.
Colocation: tests, styles and stories sit next to the component they cover. In the Next.js App Router the app/ folder is the routing tree, so features live outside it.
Raise the problems in order of impact: first the bugs (the effect ignores teamId, has no cleanup and trusts any response), then the derived state kept in an effect, then accessibility, and only then naming and style. Mark each comment as blocking or a nit, and suggest the fix.
Blocking, stale data: the effect reads teamId but its dependency list is [], so switching teams keeps showing the first team. react-hooks/exhaustive-deps reports it.
Blocking, race: once teamId is a dependency, a slow response for the previous team can arrive last and overwrite the current one. The cleanup has to ignore it or abort the request.
Blocking, errors: on an error response res.json() either rejects unhandled or puts the error body into users, and users.filter then crashes the render. Check res.ok and render an error state; a loading state is missing too.
Should fix, derived state: visible is computed from users and query; storing it costs a second render per change and can paint a list one update behind. Compute it during render.
Should fix, accessibility: a clickable div cannot be reached with the keyboard, and the input has no label. Use a <button> and an aria-label or a <label>.
Nits: the filter is case-sensitive.
How you say it is graded too: explain why each point matters, offer code instead of "this is wrong", and ask where the intent is unclear.
Follow the native input contract: accept value with onChange for controlled use and defaultValue for uncontrolled use, keep internal state only while value is undefined, and call onChange in both modes. A small useControllableState hook keeps that logic in one place.
Decide once: the mode is fixed at mount. Switching from undefined to a value later is a bug; React warns about it for native inputs, and your component should warn in development too.
Always notify: onChange fires in both modes, so an uncontrolled caller can still listen.
Conventional names: open/defaultOpen/onOpenChange, value/defaultValue/onValueChange. Radix and React Aria use exactly these, so users already know them.
Why both: controlled is needed when the parent syncs the value (URL, a form library, validation); uncontrolled is less code for the common case.
Anti-pattern: copying a prop into state with useState(props.value) plus an effect that syncs it. Two sources of truth drift apart.
One at the root as the last resort, one per route so a broken page keeps the navigation alive, and one around each widget that can fail on its own (a chart, a comments block). Pair each with a Suspense boundary at the same level and give it a way to reset.
Granularity
Too few, and one failing widget blanks the whole page. Too many, and the page becomes a patchwork of small error messages, each of which needs designing.
Route level is the usual default: the React Router ErrorBoundary route export and the Next.js error.tsx file give you one per route segment.
With Suspense
Suspense handles "not ready yet", the error boundary handles "failed". Nest them as a pair, ErrorBoundary outside and Suspense inside, so a rejected promise read with use() or useSuspenseQuery shows the error in the same spot as the loading state.
Reset
After showing its fallback, a boundary stays in the error state until reset. react-error-boundary provides resetErrorBoundary and resetKeys (for example, reset when the path changes); changing the boundary's key does the same by remounting.
Reset the cause too: TanStack Query's QueryErrorResetBoundary clears failed queries so the retry actually refetches.
Reporting
React 19's createRoot(el, { onCaughtError, onUncaughtError, onRecoverableError }) is one place to send every error to Sentry, instead of componentDidCatch in each boundary.
Capture every source of errors, make each report point at your code and your release, filter the noise, and alert on what is new or growing instead of on every event. A tool such as Sentry does the plumbing; the setup decides whether anyone reads the reports.
Capture everything
React 19 root options onCaughtError, onUncaughtError and onRecoverableError (Sentry wraps them with Sentry.reactErrorHandler()) see render errors; the SDK's window.onerror and unhandledrejection hooks see event handlers and async code.
A failed request is not an exception: report 5xx and network failures once, in the API client, grouped by route template rather than by a URL with ids in it.
Make it readable
Upload each build's source maps from CI and keep them private, so stack traces show your files and lines.
release set to the commit SHA lets the tool mark issues as new in a release, catch regressions of resolved ones and point at suspect commits; environment keeps staging apart.
Context: user id (not email), route, feature flags, and breadcrumbs of the clicks, navigations and requests before the crash.
Cut the noise
Drop errors from browser extensions and third-party scripts (denyUrls, the bare Script error. of cross-origin scripts) and known harmless ones such as the ResizeObserver loop warning.
When one bug splits into hundreds of issues because the message contains an id, set a custom fingerprint.
Scrub tokens and personal data in beforeSend.
Act on it
Alert on a new issue, a regression or an error-rate spike after a deploy, routed to the team that owns the code.
Watch crash-free sessions per release during a staged rollout: that number is the rollback signal.
Give each feature one public entry (features/cart/index.ts) and enforce it with a lint rule that rejects deep imports like features/cart/ui/CartRow. The folder structure then becomes a contract that CI checks rather than a convention people forget.
Enforcement: no-restricted-imports with patterns, eslint-plugin-boundaries for layer rules, or @nx/enforce-module-boundaries in an Nx monorepo.
Pitfall 1, cycles: two features importing each other's index.ts form a circular import, and a value can be undefined at the moment a module reads it. A cycle means the shared part belongs in shared/ or an entity layer.
Pitfall 2, loading everything: importing one name from a large export * barrel makes the dev server, tests and sometimes the bundle load every module behind it. Tree-shaking only drops the rest when modules are free of side effects ("sideEffects": false). Avoid one giant barrel for a big library such as an icon set.
Communication between features goes through the page that composes them, a shared entity or an event, not through direct imports.
A monorepo pays off when several apps share code (a design system, an API client, configs) and you want one PR to change a library and all its consumers together. The usual setup is pnpm workspaces plus Turborepo or Nx for cached builds of only what changed.
Gains: atomic changes across packages, one lockfile and one version of React, shared TypeScript, ESLint and Vitest configs, refactors the type checker verifies across every app.
Costs: CI must build and test only affected packages (turbo run test --filter=...[origin/main], nx affected) or it grows with every package; ownership needs rules such as CODEOWNERS.
Internal packages: "@acme/ui": "workspace:*" links a package without publishing it. Either build each package, or point exports at the TypeScript source and let the app's bundler compile it.
Classic bug: a library that lists react in dependencies installs a second copy, and hooks fail with "Invalid hook call". Library packages declare react as a peerDependency.
Versioning: Changesets when packages are also published; otherwise everything ships from main together.
Not a fit: unrelated products with separate teams and release cycles; separate repos and a published package are simpler.
Go through React 18.3 first, which warns about every API React 19 removes, fix those warnings, run the official codemods, then upgrade the libraries whose peer range stops at 18. What breaks is old code: legacy root APIs, class-era features, libraries built on findDOMNode, and types.
Removed APIs
ReactDOM.render and hydrate (now createRoot and hydrateRoot), unmountComponentAtNode, findDOMNode, string refs, legacy context (contextTypes), createFactory, and everything in react-dom/test-utils except act, which moved to react.
propTypes checks are silently ignored and defaultProps on function components is gone, so defaults move into destructuring. Nothing crashes: the component quietly receives undefined.
The new JSX transform is required.
Libraries
A dependency whose peerDependencies stop at ^18 blocks the install, and forcing past it only hides the breakage. Upgrade it, or replace it if it is dead (Enzyme, react-beautiful-dnd).
Anything still calling findDOMNode, such as old react-transition-group code without nodeRef, fails at runtime.
Behaviour and types
Render errors are no longer re-thrown: uncaught ones go to window.reportError, caught ones to console.error. A reporter that listens only to window.onerror misses errors caught by an error boundary: add onCaughtError on the root.
Types: useRef() needs an argument, a ref callback may not return a value implicitly, the global JSX namespace becomes React.JSX, and ReactElement['props'] is unknown. The types codemod fixes most of it.
Rollout
An app runs one copy of React, so this is a single switch: test the branch with StrictMode on, then release it to a small share of users and compare error rates before going to everyone.