Skip to content
React Course1.0.0 beta
CSEN
React Course1.0.0 beta
CSEN
🏠 Home🧭 Where to start?🔁 Review📄 Cheat sheets📰 What's newℹ️ About the course
0. JavaScript0/22

Basics

  • 1. A quick review of the basics0/3
  • 2. State and events (useState)0/3

Hooks in depth

  • 3. Effects (useEffect)0/3
  • 4. Refs (useRef)0/3
  • 5. More complex state (useReducer)0/3
  • 6. Context0/3
  • 7. Custom hooks0/3

Forms

  • 8. Forms and React 19 Actions0/3

Performance

  • 9. Performance and rendering0/3

Patterns

  • 10. Component design patterns0/3

Ecosystem

  • 11. Where the type goes (TypeScript syntax)0/3
  • 12. TypeScript with React0/3
  • 13. Routing (React Router)0/3
  • 14. Data fetching0/3
  • 15. Application state management0/3

Project

  • 16. Final project: Kanban0/2

Summary

  • 17. Summary: the principles of React

Your account

Privacy

Lesson 10 · Patterns

Component design patterns

Composition, compound components, render props, portals and error boundaries.

Loading lesson…

💬 Found a mistake, something unclear, or have an idea? Let me know.
← Performance and renderingWhere the type goes (TypeScript syntax) →

Before we start: Lego, or a finished toy?

A finished toy car from the shop is lovely, but it can only do what the manufacturer thought of. Want different wheels? Tough luck. Lego is a different approach: you get a set of bricks that fit together, and you build what you need. The manufacturer didn't have to foresee every wish.

It's the same with components. You can write a "finished toy" – a component that controls everything itself through dozens of settings. Or a set of bricks that know how to work together, from which the component's user builds what they want. The design patterns in this lesson are proven ways to design such bricks.

For every component we ask the same question: how much control to give whoever uses it – and how to give it without overwhelming them.

ℹ️ What's in this lesson
  1. Composition instead of configuration.
  2. Compound components: bricks that cooperate (Tabs).
  3. Render props: the logic is mine, the look is yours.
  4. A component that can control itself or let itself be controlled.
  5. Portals: rendering outside the parent.
  6. Error boundaries: one part crashing doesn't take down the whole app.

1. Composition instead of configuration

This is what a card looks like that started simple and then grew with every new requirement:

// ❌ A "prop explosion" – every requirement = a new prop
<Card
title="Order"
subtitle="No. 1234"
icon="cart"
showFooter
footerText="Pay"
footerAlign="right"
onFooterClick={pay}
/>

What if a requirement comes for two buttons in the footer? Or for a link in the title? You add more props and the component fills up with conditions inside. It's a finished toy the manufacturer keeps reworking.

// ✅ Composition – the user puts together what they need
<Card>
<Card.Header icon={<CartIcon />}>
Order <small>No. 1234</small>
</Card.Header>
<Card.Body>…</Card.Body>
<Card.Footer>
<Button variant="ghost">Back</Button>
<Button onClick={pay}>Pay</Button>
</Card.Footer>
</Card>

Card now handles only the frame, padding and shadows. The content is supplied by whoever uses it – and when they want two buttons, they just put them there. The component doesn't have to change for that.

2. Compound components: bricks that cooperate

An orchestra: the conductor stands in the middle and the musicians watch them. Each musician knows when to play because they can see the conductor – not because someone passed them a note. And you, as the organiser, just decide on the line-up: how many violins, where the piano goes.

Compound components work the same way. They're a set of components that belong together – like <select> and <option> in HTML. The main component is the conductor: it holds the state and "broadcasts" it through a hidden context. The other components watch it. The user puts together the line-up.

// This is how the user uses it:
<Tabs defaultValue="react">
<Tabs.List>
<Tabs.Tab value="react">React</Tabs.Tab>
<Tabs.Tab value="vue">Vue</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="react">⚛️ A UI library</Tabs.Panel>
<Tabs.Panel value="vue">🟩 A progressive framework</Tabs.Panel>
</Tabs>

And this is how it's built inside:

const TabsContext = createContext<{ active: string; setActive: (v: string) => void } | null>(null)
// The conductor: holds the state and broadcasts it
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue)
return <TabsContext value={{ active, setActive }}>{children}</TabsContext>
}
// A musician: watches the conductor
function Tab({ value, children }) {
const { active, setActive } = useTabs()
return (
<button aria-selected={active === value} onClick={() => setActive(value)}>
{children}
</button>
)
}
function Panel({ value, children }) {
const { active } = useTabs()
return active === value ? <div>{children}</div> : null
}
Tabs.Tab = Tab // so you can write <Tabs.Tab>
Tabs.Panel = Panel
❓ How does Tabs.Tab = Tab work? Can a function have properties?

It can. In JavaScript a function is an object too, so you can assign a property to it like to any object. Tabs.Tab = Tab just stores the Tab component under the name Tab on the Tabs function. The JSX <Tabs.Tab> then means "the component in the Tab property of the Tabs object".

It's just a convenience: the user imports one name (Tabs) and the dot shows straight away which bricks belong together. You could just as well export Tabs, TabsTab and TabsPanel separately. It would work the same, the relationship would just be less visible.

What happens after clicking the "Vue" tab:

  1. The Tab with value="vue" calls setActive('vue') – a function it got from the context.
  2. The state in Tabs changes. It broadcasts a new context value.
  3. All the Tabs and Panels re-render: the "Vue" button is active, the React panel returns null, the Vue panel shows up.

The component's user deals with none of this. They don't pass active or onChange to every button. And yet they can put anything of their own into the line-up – an icon, a badge, a separator.

Demo

Tabs as a compound component

What you see: The tabs from the example above. The Svelte tab has its own "new" badge – the component's user put it there themselves, without Tabs needing a prop for badges.

Try it:

  1. Switch between the tabs. The active button and the content change together.
  2. Open the code and find CompoundTabsDemo at the bottom. Notice that no Tab gets active or onClick – it takes everything from the context.
  3. Find useId() in the code. Thanks to it the IDs for screen readers are unique, even if there were several sets of tabs on the page.

The takeaway: The conductor holds the state, the musicians watch it, the user puts together the line-up. The logic is in one place, the structure is free.

Loading the interactive part…

📍 Where to use it
Tabs, Accordion, Select, Menu, Dialog, Stepper… This is how Radix UI, React Aria, Headless UI and shadcn/ui are built.

3. Render props: the logic is mine, the look is yours

A photo booth at the station takes care of the light, the countdown, the click and the printing. Only the pose is up to you. The booth doesn't need to know whether you'll smile or pull a face.

A render prop is the same idea: the component handles the logic (filtering, paging, the empty state) and gets a function from you that says what one item should look like.

function FilterableList<T>({ items, getText, renderItem }: {
items: T[]
getText: (item: T) => string
renderItem: (item: T) => ReactNode // ← the render prop
}) {
const [query, setQuery] = useState('')
const filtered = items.filter((i) => getText(i).toLowerCase().includes(query.toLowerCase()))
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<ul>{filtered.map(renderItem)}</ul> {/* the caller supplies the look */}
</>
)
}
// The same logic, two different looks:
<FilterableList items={products} getText={(p) => p.name}
renderItem={(p) => <li key={p.id}>{p.name} – <b>${p.price}</b></li>} />
<FilterableList items={colors} getText={(c) => c}
renderItem={(c) => <li key={c}>🎨 {c}</li>} />

The generic type T makes sure the renderItem function gets a correctly typed item: Product for products, string for colours.

Demo

A generic filtered list

What you see: Two lists built from the same FilterableList component. On the left products with a price, on the right colours with an icon and their own text for an empty result.

Try it:

  1. Type a few letters of a product name into the left field. Filtering works – the booth took care of it.
  2. Type "blu" into the right one. The same logic, a completely different look for the items.
  3. Type something that doesn't exist. Each list shows its own empty state – that can be passed in from outside too.

The takeaway: The logic is written once and each use decides the look. The component doesn't need to know what the items look like.

Loading the interactive part…

ℹ️ How it works
Many old render props (e.g. <Mouse>{pos => …}</Mouse>) have been replaced by custom hooks today. A render prop makes sense when the component controls the structure (a virtualised list, a table, an autocomplete) and only needs the look of one item from you.

4. A component that can let itself be controlled

The thermostat in a flat has two modes. In automatic mode it watches the temperature itself. In manual mode you set it and it just obeys. It's still the same device – only who decides is different.

A good UI component works the same way – exactly like <input> from the lesson on forms:

  • <Switch defaultChecked /> – automatic: it keeps its state itself (uncontrolled).
  • <Switch checked={wifi} onChange={setWifi} /> – manual mode: the parent keeps the state (controlled).
function useControllableState<T>(controlled: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
const [internal, setInternal] = useState(defaultValue) // its own memory for automatic mode
const isControlled = controlled !== undefined // did we get a value from outside?
const value = isControlled ? controlled : internal // who decides
const setValue = (next: T) => {
if (!isControlled) setInternal(next) // in automatic mode, write it down yourself
onChange?.(next) // always let the parent know
}
return [value, setValue] as const
}
  1. The component checks whether it got checked. If it did, it's in manual mode and shows the value from the parent.
  2. After a click, in automatic mode it changes its own state. In manual mode it doesn't change it – it just calls onChange and waits for the parent to send a new value.
  3. When the parent doesn't pass onChange, nobody changes the value – the switch is read-only.
Demo

A Switch with useControllableState

What you see: Three switches of the same Switch component: the first in automatic mode, the second controlled by the parent (the wifi state), the third controlled without onChange. At the bottom, a button the parent uses to change wifi.

Try it:

  1. Flip the first switch. It works – it keeps its state itself.
  2. Flip the second one. The wifi = … text in its label changes too – the parent holds the value and the switch only informs it.
  3. Click The parent switches wifi off. The second switch turns off without anyone touching it – the parent decides.
  4. Try flipping the third one. It doesn't work – it's controlled, but nobody listens to its changes.

The takeaway: One component, two modes. The user chooses: let it manage by itself, or control it.

Loading the interactive part…

5. Portals: rendering outside the parent

A class makes a poster about the school trip. If it hung only in the classroom, behind a cupboard, nobody would see it. So they pin it to the noticeboard in the hall. The poster still belongs to that class – they made it and look after it – it just hangs somewhere else.

Modals, tooltips and dropdown menus have the same problem. When you render them inside a card that has overflow: hidden or transform, the card clips or covers them. A portal renders them elsewhere in the DOM (typically into <body>), but in the React tree they stay where they are:

import { createPortal } from 'react-dom'
function Modal({ children, onClose }: { children: ReactNode; onClose: () => void }) {
return createPortal(
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>{children}</div>
</div>,
document.body, // where to "pin" it in the DOM
)
}
  • In the DOM the modal is right in <body> – no card clips it.
  • In the React tree it's still a child of the component that rendered it. Context works and events bubble to the React parents, not to <body>.
  • That's why we call e.stopPropagation() in the modal: without it a click on the content would "bubble up" to the backdrop and close the modal.
Demo

A modal with and without a portal

What you see: A card with overflow: hidden and transform – a typical trap for modals. Two buttons open the same modal: once rendered in place, once through a portal.

Try it:

  1. Click Open without a portal. The modal is trapped in the card, clipped, and doesn't cover the page.
  2. Close it and click Open through a portal. A modal over the whole screen, as it should be.
  3. Click on the modal's content – it doesn't close. Click on the dark backdrop – it closes. That's stopPropagation in action.

The takeaway: A portal changes where an element hangs in the DOM, but not whom it belongs to. An escape from clipping without losing context and events.

Loading the interactive part…

📍 Where to use it
Modals, tooltips, popovers, dropdowns, toasts – anything that has to "break free" from its parents' overflow and z-index.

6. Error boundaries: fuses in the fuse box

When the kettle shorts out in the kitchen, a fuse blows – but only for the kitchen. In the living room the lights stay on and the TV keeps running. If the whole flat were on one fuse, every short circuit would mean darkness everywhere.

Without protection, a React app behaves like a flat with one fuse: an error while rendering any component takes down the whole page – the user sees an empty white screen. An error boundary is a fuse for one part of the tree: it catches the error and shows fallback content in place of the crashed part.

<ErrorBoundary fallback={<p>The chart couldn't be displayed.</p>}>
<SalesChart />
</ErrorBoundary>
<ErrorBoundary fallback={<p>The order list is unavailable.</p>}>
<OrderList />
</ErrorBoundary>

When SalesChart throws an error while rendering, React climbs up the tree to the nearest boundary and renders its fallback. OrderList keeps running. For now a boundary has to be a class (there's no hook equivalent); in practice people use the react-error-boundary library.

Demo

Isolated crashes

What you see: Two widgets, each in its own boundary. Both crash on purpose at number 3 – they throw an error while rendering.

Try it:

  1. Click Widget A three times. An error message appears in its place – the fuse blew.
  2. Widget B keeps working. Click it – it counts normally.
  3. Click Try again on the crashed widget. The boundary resets and the widget starts from zero.

The takeaway: Each boundary protects only its own part. An error in one widget doesn't take down the other or the rest of the page.

Loading the interactive part…

A boundary catchesA boundary does NOT catch
Errors while descendants renderErrors in event handlers (use try/catch)
Errors in descendants’ effectsAsynchronous errors (setTimeout, a promise outside render)
A rejected Promise in use()An error in the boundary itself
✨ Tip
Put fuses at several levels: one around the whole app ("Something went wrong"), more around independent widgets and pages. This course wraps every demo and challenge – that's why an error in your solution doesn't take down the whole page.

Check yourself

Quiz A button in a modal rendered through a portal into <body> fires onClick. Will the onClick on a <div> that is the modal's parent in the React tree catch it?
Quiz An error happens in a button's onClick handler. Will an error boundary catch it?
Quiz A Select component has the props showIcon, iconPosition, groupBy, renderGroupHeader, footerText… What would help?

Warm-up

Challenge

A card with content

Warm-up

One card, different content. You just need to render it in the right place.

Task (the same text is in the comment at the top of the file)
CHALLENGE: A card with content

Card gets a title and content (children), but it doesn't render the content yet.

1. Render {children} into the <div> under the title
🧩 Where do the things not defined here come from
CSS classes like row, stack, card, list, btn, input or muted are the course's ready-made styles in src/styles.css (row = side by side, stack = stacked, card = bordered box, muted = grey text).

Loading the interactive part…

Challenges

The accordion practises section 2: the user-facing API is finished, your task is to build the conductor and the musicians. The modal combines section 5 with refs and effects – and on top of that it has to work with the keyboard and a screen reader.

❓ Why does the Accordion have two contexts?

The Header and the Panel need two different pieces of information from two different places. From the whole accordion they need to know what's open and have a function to toggle it. That's broadcast by Accordion. From their item they need to know which item they belong to (value). That's broadcast by Item.

<Accordion> {/* AccordionContext: { isOpen, toggle } */}
<Accordion.Item value="a"> {/* ItemContext: { value: 'a', id } */}
<Accordion.Header /> {/* reads both: isOpen('a') and toggle('a') */}
<Accordion.Panel />
</Accordion.Item>
</Accordion>

Without the second context you'd have to write value once more into every Header and Panel, and that's exactly the repetition compound components try to avoid.

❓ What are aria-expanded, aria-controls, role and useId?

The aria-* and role attributes display nothing. They describe the page for screen readers (blind users) and other assistive technologies:

  • aria-expanded={true/false} on a button: "this button expands content and right now it's expanded/collapsed".
  • aria-controls="panel-id": "this button controls the element with this id".
  • role="dialog", role="region": "this div is really a dialog / a separate region".

To connect them you need an id that's unique on the page, even when there are several accordions. useId() generates it: different for each component, and stable between renders. Don't use Math.random() – that would change on every render.

Challenge

A compound Accordion

Medium Bonus challenge

The API is finished – your task is to get the components working "under the bonnet".

🔒 Bonus challenge

Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.

How we handle your data
Challenge

An accessible modal

Hard Bonus challenge

A modal that works with the keyboard and a screen reader – and that its parents' styles can't clip.

🔒 Bonus challenge

Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.

How we handle your data

Summary

  • Lego, not a finished toy: composition (children, slots) instead of dozens of props.
  • Compound components are an orchestra: the conductor holds the state in a hidden context, the musicians watch it, the user puts together the line-up.
  • Render props – a photo booth: the logic in the component, the look from the caller. Often replaced by a hook.
  • A good component is a thermostat: it can be uncontrolled (defaultX) and controlled (x + onChange).
  • A portal – a poster in the hall: it hangs elsewhere in the DOM, but still belongs to its parent.
  • An error boundary – a fuse for part of the tree. It catches render errors, not handler or async errors.