Lesson 10 · Patterns
Component design patterns
Composition, compound components, render props, portals and error boundaries.
Loading lesson…
Lesson 10 · Patterns
Composition, compound components, render props, portals and error boundaries.
Loading lesson…
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.
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<Cardtitle="Order"subtitle="No. 1234"icon="cart"showFooterfooterText="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.
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 itfunction Tabs({ defaultValue, children }) {const [active, setActive] = useState(defaultValue)return <TabsContext value={{ active, setActive }}>{children}</TabsContext>}// A musician: watches the conductorfunction 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
What happens after clicking the "Vue" tab:
Tab with value="vue" calls setActive('vue') – a function it got from the context.Tabs changes. It broadcasts a new context value.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.
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:
CompoundTabsDemo at the bottom. Notice that no Tab gets active or onClick – it takes everything from the context.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…
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) => stringrenderItem: (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.
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:
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…
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 modeconst isControlled = controlled !== undefined // did we get a value from outside?const value = isControlled ? controlled : internal // who decidesconst setValue = (next: T) => {if (!isControlled) setInternal(next) // in automatic mode, write it down yourselfonChange?.(next) // always let the parent know}return [value, setValue] as const}
checked. If it did, it's in manual mode and shows the value from the parent.onChange and waits for the parent to send a new value.onChange, nobody changes the value – the switch is read-only.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:
wifi = … text in its label changes too – the parent holds the value and the switch only informs it.The takeaway: One component, two modes. The user chooses: let it manage by itself, or control it.
Loading the interactive part…
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)}
<body> – no card clips it.<body>.e.stopPropagation() in the modal: without it a click on the content would "bubble up" to the backdrop and close the modal.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:
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…
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.
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:
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 catches | A boundary does NOT catch |
|---|---|
| Errors while descendants render | Errors in event handlers (use try/catch) |
| Errors in descendants’ effects | Asynchronous errors (setTimeout, a promise outside render) |
A rejected Promise in use() | An error in the boundary itself |
One card, different content. You just need to render it in the right place.
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 titlerow, 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…
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.
The API is finished – your task is to get the components working "under the bonnet".
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
A modal that works with the keyboard and a screen reader – and that its parents' styles can't clip.
Medium and hard challenges are a bonus for signed-in readers. Signing up is free – just an e-mail, no password.
defaultX) and controlled (x + onChange).