Compound components are parts that work together and share state through context. Build an accordion, make it flexible, controlled and accessible, and learn when not to.
In Part 9 we built an accordion. An accordion is a list of sections. Each section has a button with a title, and clicking it opens or closes the text under it. In Part 9, the parent kept the state, openIndex, and passed isOpen to each panel as a prop. The state was a number, the position of the open panel. We’ll see why a name is safer.
Since then, you have learned two more tools. Part 3 showed children, whatever you put between a component’s tags. Part 23 showed context, which lets a component read a value from far above it. Part 24 put state and context together in a provider component.
This part starts a new stage, on patterns. Part 24 said a pattern is a way of writing code that many people use for the same kind of problem. Our first one is compound components: a group of components that work together and share state. We’ll build an accordion this way, step by step. Then we’ll make it flexible, controlled and accessible. And we’ll see when a plain list of props is the better choice.
Try this first
Read this code. Don’t press Run yet.
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type AccordionState = {
openValue: string | null
setOpenValue: (value: string | null) => void
}
const AccordionContext = createContext<AccordionState | null>(null)
const ItemContext = createContext<string | null>(null)
function useAccordion() {
const accordion = useContext(AccordionContext)
if (accordion === null) {
throw new Error('Accordion parts must be used inside <Accordion>')
}
return accordion
}
function useItemValue() {
const value = useContext(ItemContext)
if (value === null) {
throw new Error('Accordion.Trigger and Accordion.Panel must be used inside <Accordion.Item>')
}
return value
}
function Accordion({ children }: { children: ReactNode }) {
const [openValue, setOpenValue] = useState<string | null>(null)
const state = useMemo(() => ({ openValue, setOpenValue }), [openValue])
return (
<AccordionContext value={state}>
<div>{children}</div>
</AccordionContext>
)
}
function Item({ value, children }: { value: string; children: ReactNode }) {
return (
<ItemContext value={value}>
<div>{children}</div>
</ItemContext>
)
}
function Trigger({ children }: { children: ReactNode }) {
const { openValue, setOpenValue } = useAccordion()
const value = useItemValue()
const isOpen = openValue === value
return <button onClick={() => setOpenValue(isOpen ? null : value)}>{children}</button>
}
function Panel({ children }: { children: ReactNode }) {
const { openValue } = useAccordion()
const value = useItemValue()
if (openValue !== value) {
return null
}
return <div>{children}</div>
}
Accordion.Item = Item
Accordion.Trigger = Trigger
Accordion.Panel = Panel
export default function App() {
return (
<Accordion>
<Accordion.Item value="shipping">
<Accordion.Trigger>Shipping</Accordion.Trigger>
<Accordion.Panel>We ship in 2 days.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="returns">
<Accordion.Panel>You have 30 days to send it back.</Accordion.Panel>
<Accordion.Trigger>Returns</Accordion.Trigger>
</Accordion.Item>
</Accordion>
)
}
Look at the bottom, at App. It is the only part you would write to use the accordion. The rest is the accordion itself.
In the second item, the panel comes before its button. Nothing tells the panel which button is its own. No prop connects them.
Make a guess. When you click “Returns”, will its text open? If it does, will it appear above the button or below it?
Now press Run and click “Returns”.
The text opens, above the button, where you put it. Click “Shipping” and the shipping text opens, while the returns text closes. Click “Shipping” again and it closes too.
So the panel found its own item and its own accordion, with no props. This part explains how.
The problem: one component, a long list of props
First, the usual way to build an accordion. One component takes the data as props and writes all the tags itself:
type Section = { title: string; body: string }
type AccordionProps = {
items: Section[]
allowMultiple?: boolean
startOpen?: boolean
canCloseAll?: boolean
showIcons?: boolean
iconOnLeft?: boolean
boldTitles?: boolean
titlesAsHeadings?: boolean
showDividers?: boolean
showBorder?: boolean
compact?: boolean
showCountBadge?: boolean
disableLastItem?: boolean
}
function Accordion({ items, compact }: AccordionProps) {
// ...an `if` for each setting, all in here
return <div>{compact ? 'small' : 'big'} accordion with {items.length} sections</div>
}
const faq: Section[] = [{ title: 'Shipping', body: 'We ship in 2 days.' }]
const page = <Accordion items={faq} allowMultiple startOpen iconOnLeft compact showDividers />
?: in a type means the prop is optional. A true or false prop is called a boolean prop. Writing just its name, like compact, means compact={true}.
It starts small: items, and nothing else. Then one page needs two sections open at once, so we add allowMultiple. Another page wants icons, so we add showIcons. Then someone wants the icons on the left. Each request adds a prop, and an if inside the component. This one has 12 boolean props, and it still can’t do everything.
Some settings don’t work together. What should startOpen do when allowMultiple is off and three sections ask to start open? The component has to pick, and the person using it has to read the code to find out.
And some requests don’t fit at all. Say one team wants a small “New” label next to one title. Or a note between two sections. The component writes every tag itself, so the user can’t add anything. The only way is prop number 13.
Compound components: parts that work together
HTML has already solved this kind of problem. Look at a menu made with <select>:
export default function App() {
return (
<select aria-label="Pet" defaultValue="cat">
<option value="dog">Dog</option>
<option value="cat">Cat</option>
<option value="fish">Fish</option>
</select>
)
}
Run it. The menu shows “Cat”, and you can pick another pet.
MDN, the web’s main guide to HTML, says: “Each menu option is defined by an <option> element nested inside the <select>.”
The <select> keeps track of which option is picked. In React, you don’t tell each <option> whether it is picked. You give the <select> a defaultValue or a value, as Part 28 showed. You don’t pass a list of options as data either. You write the options yourself, as tags, as many as you want.
<select> and <option> are two tags that only make sense together. They share what is picked without you passing it along. That is the idea we copy.
Compound components are a group of components that work together. The parent holds the shared state. Each part reads what it needs by itself, so you don’t pass the state to each part as a prop. The person using them writes the parts as tags, in their own JSX.
React’s docs suggest this shape too. Their page on the Children API shows a list component that also gives you a Row component. You wrap each row yourself. The docs say this way “works even if you keep extracting more components”. We’ll test that below.
How the accordion works
Go back to “Try this first”. The accordion has four parts. The person using it writes them like this:
<Accordion>
<Accordion.Item value="shipping">
<Accordion.Trigger>Shipping</Accordion.Trigger>
<Accordion.Panel>We ship in 2 days.</Accordion.Panel>
</Accordion.Item>
</Accordion>
Accordionholds the state:openValue, the name of the open item, ornullwhen none is open. It gives the state to everything inside it throughAccordionContext. It is a provider component, as in Part 24.Accordion.Itemis one section. Itsvalueprop is its name, like"shipping". It gives that name to everything inside it through a second context,ItemContext.Accordion.Triggeris the button. A trigger is the thing you press to make something happen. It reads both contexts. When you click it, it opens its item. If its item is already open, it closes it.Accordion.Panelis the text. It reads both contexts too. It returnsnull, which shows nothing, unless its item is the open one.
So each part learns two things from context. From AccordionContext: which item is open. From ItemContext: which item am I in. Neither needs a prop from the person using the accordion.
Two small things in the code come from Part 23.
- The value of
AccordionContextis an object. A new object on every render would make every part render again for nothing. That was Part 23’s object trap. SouseMemokeeps the same object untilopenValuechanges. This pays off only when the components in between are skipped, for example bymemo. Otherwise the parts render anyway, because their parents did. You’ll see both under Common mistakes. ItemContextholds a plain string, the item’s name. A string can’t fall into the object trap, so it needs nouseMemo.
Here is one click, step by step.
One click on a compound accordion. Press play, or step through it.
The same steps in words:
AccordionholdsopenValue: null. It shares it throughAccordionContext. EachItemshares its own name throughItemContext.- You click the “Returns” trigger. Its
ItemContextsays"returns", so it callssetOpenValue("returns"). Accordion‘s state changes, so React rendersAccordionagain. Itschildrenare the same elements as before, so the items don’t render again.- The context value is a new object now. So every trigger and every panel renders again, and reads it.
- The returns panel sees that its item is open, and shows its text. The shipping panel still returns
null. - You click “Shipping”.
openValuebecomes"shipping". One panel opens, and the other closes.
We counted step 3 and step 4 with Strict Mode off. One click rendered Accordion once, each trigger once and each panel once. The items rendered 0 times.
An everyday example
Think of a class. The teacher keeps track of who may speak. Each student has a card with their own name on it. A student holds up the card, and the teacher gives that student the turn. Every student watches the teacher to see if it is their turn now.
The students can sit in any order. Nobody passes a note down the row. Each one watches the teacher.
The teacher is Accordion. The name on the card is the item’s value. Holding up the card is the trigger. Watching the teacher is reading the context.
The exact version
A part doesn’t find its accordion by where it stands on the page. It finds the nearest AccordionContext provider above it in the tree. Part 23 called this the nearest provider wins. So you can put a whole accordion inside another accordion’s panel. The inner parts talk to the inner accordion. We checked: a click inside the inner one changed only the inner one.
The two contexts are found one by one, though. Say an inner trigger sits inside the inner <Accordion>, but not inside an inner item. It still finds an ItemContext: the outer item’s. We tried it. The trigger read the item name "outer", and set the inner accordion’s openValue to "outer". Nothing opened, and nothing threw an error. So put every trigger and every panel inside its own item.
The class also hides a cost. In the class, only the student whose turn changed needs to look up. In React, every part that reads the context renders again when its value changes. That is every trigger and every panel, even the ones that stay the same. For a short list this is cheap. Part 25 shows ways to read only the part you need.
Dot notation: Accordion.Item
Look at these three lines from “Try this first”:
Accordion.Item = Item
Accordion.Trigger = Trigger
Accordion.Panel = Panel
In JavaScript, a function is also an object. So you can put properties on it, like on any object. Here we put the three parts on Accordion. This way of writing names with a dot, Accordion.Item, is called dot notation.
It helps in two ways. You import one name, Accordion, and get all four parts. And the name Accordion.Trigger says which parent the trigger belongs to. A plain Trigger could belong to anything.
TypeScript allows this for a component made with function, as in “Try this first”. It also allows it for one made with const and an arrow function. TypeScript sees the extra lines and adds Item, Trigger and Panel to the function’s type.
It breaks when you give the component a type by hand. Many projects write components with the type FC, short for function component:
import type { FC, ReactNode } from 'react'
function Item({ children }: { children: ReactNode }) {
return <div>{children}</div>
}
const Accordion: FC<{ children: ReactNode }> = ({ children }) => <div>{children}</div>
Accordion.Item = Item
TypeScript says: Property 'Item' does not exist on type 'FC<{ children: ReactNode; }>'. You told it Accordion is an FC, and an FC has no Item.
There are two fixes. Drop the FC type and write a plain function. Or build the whole thing with Object.assign, which copies properties onto an object and returns it:
import type { ReactNode } from 'react'
function Item({ children }: { children: ReactNode }) {
return <div>{children}</div>
}
function AccordionRoot({ children }: { children: ReactNode }) {
return <div>{children}</div>
}
const Accordion = Object.assign(AccordionRoot, { Item })
const page = (
<Accordion>
<Accordion.Item>Hello</Accordion.Item>
</Accordion>
)
Both type-check. We checked both with TypeScript 7.0.2.
Dot notation is a choice, not a rule. You can also export AccordionItem, AccordionTrigger and AccordionPanel as plain names. The parts work the same way. Radix UI, a well-known library of compound components, writes its parts as Accordion.Root, Accordion.Item, Accordion.Trigger and more. There, the outer part is Accordion.Root, not Accordion itself. We read the package’s code. Root, Item, Header, Trigger and Content are separate exports of one file. The radix-ui package then gathers them under one name with import * as Accordion. So the dots are names in a module, not properties on a function.
Separate exports have two gains. First, a build tool can leave out parts you don’t import. We built two small apps with the build tool that Vite 8 uses. Each app used two parts and skipped a third. With dot notation, the third part’s code was still in the build. With separate exports, it was left out.
Second, Server Components can’t use the dots. A component library like this is a 'use client' file. A Server Component that imports Accordion from it gets only a stand-in, not the real function. We ran React 19.3’s server code and read Accordion.Item from that stand-in. It threw:
Cannot access Accordion.Item on the server. You cannot dot into a client module from a server component. You can only pass the imported name through.
Flexible markup
Markup means the tags that make up a page. With compound components, the person using the accordion writes the markup. So they can change it. Here is the same accordion with more freedom:
- each trigger sits inside an
<h3>heading; - the shipping trigger has a “New” label inside it;
- there is a note between the two items;
- the returns item lives in a component of its own,
ReturnsItem.
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type AccordionState = {
openValue: string | null
setOpenValue: (value: string | null) => void
}
const AccordionContext = createContext<AccordionState | null>(null)
const ItemContext = createContext<string | null>(null)
function useAccordion() {
const accordion = useContext(AccordionContext)
if (accordion === null) {
throw new Error('Accordion parts must be used inside <Accordion>')
}
return accordion
}
function useItemValue() {
const value = useContext(ItemContext)
if (value === null) {
throw new Error('Accordion.Trigger and Accordion.Panel must be used inside <Accordion.Item>')
}
return value
}
function Accordion({ children }: { children: ReactNode }) {
const [openValue, setOpenValue] = useState<string | null>(null)
const state = useMemo(() => ({ openValue, setOpenValue }), [openValue])
return (
<AccordionContext value={state}>
<div>{children}</div>
</AccordionContext>
)
}
function Item({ value, children }: { value: string; children: ReactNode }) {
return (
<ItemContext value={value}>
<div>{children}</div>
</ItemContext>
)
}
function Trigger({ children }: { children: ReactNode }) {
const { openValue, setOpenValue } = useAccordion()
const value = useItemValue()
const isOpen = openValue === value
return <button onClick={() => setOpenValue(isOpen ? null : value)}>{children}</button>
}
function Panel({ children }: { children: ReactNode }) {
const { openValue } = useAccordion()
const value = useItemValue()
if (openValue !== value) {
return null
}
return <div>{children}</div>
}
Accordion.Item = Item
Accordion.Trigger = Trigger
Accordion.Panel = Panel
function ReturnsItem() {
return (
<Accordion.Item value="returns">
<h3>
<Accordion.Trigger>Returns</Accordion.Trigger>
</h3>
<Accordion.Panel>You have 30 days to send it back.</Accordion.Panel>
</Accordion.Item>
)
}
export default function App() {
return (
<Accordion>
<Accordion.Item value="shipping">
<h3>
<Accordion.Trigger>
Shipping <small>New</small>
</Accordion.Trigger>
</h3>
<Accordion.Panel>We ship in 2 days.</Accordion.Panel>
</Accordion.Item>
<p>Questions? Write to us.</p>
<ReturnsItem />
</Accordion>
)
}
Run it, and click both buttons. Everything still works.
The accordion’s code didn’t change at all. Only App changed. The heading, the label and the note are plain tags. ReturnsItem is a component the accordion knows nothing about. Its trigger still finds the accordion, because context passes through any number of components in between. That is the docs’ “works even if you keep extracting more components”, and we checked it here.
Compare this with the props accordion. The “New” label would have needed a new prop. Here it needed nothing.
One open, or many
So far, opening one item closes the other. Sometimes you want many open at once. Let’s add a type prop, the way Radix UI does: type="single" or type="multiple".
To allow many open items, the state becomes a list of names: open: string[]. With type="single", the list holds one name at most.
The context also changes. Before, the parts set the state by themselves. Now they call one function, toggle. Toggle means “switch to the other state”: open if closed, closed if open. Accordion decides what toggle does, so the rules live in one place.
Controlled or uncontrolled
Part 28 showed that an <input> can work two ways. With defaultValue, the input keeps its own value. With value and onChange, the parent keeps it. Our accordion can work both ways. Two of its prop names come from React’s <input>, and one from Radix UI:
defaultValue: the items open at the start. After that, the accordion keeps its own state. This is uncontrolled.valueandonValueChange: the parent keeps the open list in its own state. The accordion shows whatvaluesays, and callsonValueChangewith the new list. This is controlled.
value and defaultValue are React’s names. onValueChange is Radix UI’s name. We use Radix’s name rather than onChange. That way it isn’t mixed up with the browser’s change event, which onChange means on an <input>. Radix UI’s docs say its accordion “Can be controlled or uncontrolled.”
Here the parent controls the accordion. So it can also open and close items with its own buttons:
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type AccordionState = {
open: string[]
toggle: (value: string) => void
}
const AccordionContext = createContext<AccordionState | null>(null)
const ItemContext = createContext<string | null>(null)
function useAccordion() {
const accordion = useContext(AccordionContext)
if (accordion === null) {
throw new Error('Accordion parts must be used inside <Accordion>')
}
return accordion
}
function useItemValue() {
const value = useContext(ItemContext)
if (value === null) {
throw new Error('Accordion.Trigger and Accordion.Panel must be used inside <Accordion.Item>')
}
return value
}
type AccordionProps = {
type?: 'single' | 'multiple'
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
children: ReactNode
}
function Accordion({ type = 'single', value, defaultValue = [], onValueChange, children }: AccordionProps) {
const [ownValue, setOwnValue] = useState(defaultValue)
const isControlled = value !== undefined
const open = isControlled ? value : ownValue
const state = useMemo(() => {
function toggle(item: string) {
let next: string[]
if (open.includes(item)) {
next = open.filter((v) => v !== item)
} else if (type === 'single') {
next = [item]
} else {
next = [...open, item]
}
if (!isControlled) {
setOwnValue(next)
}
onValueChange?.(next)
}
return { open, toggle }
}, [open, type, isControlled, onValueChange])
return (
<AccordionContext value={state}>
<div>{children}</div>
</AccordionContext>
)
}
function Item({ value, children }: { value: string; children: ReactNode }) {
return (
<ItemContext value={value}>
<div>{children}</div>
</ItemContext>
)
}
function Trigger({ children }: { children: ReactNode }) {
const { toggle } = useAccordion()
const value = useItemValue()
return <button onClick={() => toggle(value)}>{children}</button>
}
function Panel({ children }: { children: ReactNode }) {
const { open } = useAccordion()
const value = useItemValue()
if (!open.includes(value)) {
return null
}
return <div>{children}</div>
}
Accordion.Item = Item
Accordion.Trigger = Trigger
Accordion.Panel = Panel
export default function App() {
const [open, setOpen] = useState(['shipping'])
return (
<div>
<button onClick={() => setOpen(['shipping', 'returns'])}>Open all</button>
<button onClick={() => setOpen([])}>Close all</button>
<p>Open: {open.join(', ') || 'none'}</p>
<Accordion type="multiple" value={open} onValueChange={setOpen}>
<Accordion.Item value="shipping">
<Accordion.Trigger>Shipping</Accordion.Trigger>
<Accordion.Panel>We ship in 2 days.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="returns">
<Accordion.Trigger>Returns</Accordion.Trigger>
<Accordion.Panel>You have 30 days to send it back.</Accordion.Panel>
</Accordion.Item>
</Accordion>
</div>
)
}
Run it. Shipping starts open, because App‘s state starts as ['shipping']. Click “Returns”, and both are open: this accordion is type="multiple". The line “Open:” shows App‘s state, and it follows every click. Then try “Close all” and “Open all”.
A few lines are new.
const open = isControlled ? value : ownValue. If the parent gavevalue, the accordion shows that. If not, it shows its own state. This one line is what makes it controlled or uncontrolled.toggleworks out the next list. If the item is open, it takes it out. If not, it adds it, but withtype="single"the new list holds only that item.if (!isControlled): the accordion saves the list in its own state only when it is uncontrolled. A controlled accordion leaves that to its parent.onValueChange?.(next). The?.part calls the function only if the parent gave one. An uncontrolled accordion doesn’t need it.
For the uncontrolled way, give only a starting list: <Accordion defaultValue={['returns']}>. You’ll try it in Practice. As with an input, defaultValue is used only once, at the start. Part 28 showed that changing defaultValue later does nothing.
Our accordion differs from Radix UI’s in a few ways. We kept it simple.
- With
type="single", Radix uses a string forvalue, not a list. A string can hold only one name, so the parent can’t open two. Practice task 4 shows what happens with a list. - In Radix,
typeis required. Ours is'single'when you leave it out. - Radix lets you close the open item in single mode only if you add a
collapsibleprop. Ours always lets you close it. - Radix calls the panel
Content.
Using a part outside its parent
What if someone writes <Accordion.Trigger> outside an <Accordion.Item>? It has no item to open. So useItemValue throws an error that says what is wrong. This is the same idea as Part 23’s custom hook that throws.
Try it. In “Try this first”, press Edit. Move the line <Accordion.Trigger>Shipping</Accordion.Trigger> up, so it sits above <Accordion.Item value="shipping">. Run it. The app stops with this message:
Accordion.Trigger and Accordion.Panel must be used inside <Accordion.Item>
Move it further up, above <Accordion>, and wrap everything in a Fragment, <>.... Now useAccordion throws first:
Accordion parts must be used inside <Accordion>
TypeScript can’t catch this mistake. To TypeScript, <Accordion.Trigger> is a component that takes children, and that is all. It doesn’t know which tags it must sit inside. We type-checked the moved code: 0 errors. So the error at run time is the only guard. Make its message clear.
Why throw at all? Say the contexts had a default value instead of null, like openValue: null and a setOpenValue that does nothing. Then a trigger outside the accordion would show up, and clicking it would quietly do nothing. Part 24 met the same quiet bug with a cart. An error is easier to find.
Accessibility basics
Accessibility means everyone can use the page. That includes people who use only a keyboard. It also includes people who use a screen reader, a program that reads the page out loud. ARIA is a set of attributes, like aria-expanded. They tell a screen reader what a tag is and what state it is in. Part 46 covers ARIA, and Part 47 covers keyboard navigation. Here are the basics an accordion needs.
The W3C, the group that writes web standards, has a guide to common page parts. It is called the ARIA Authoring Practices Guide. Its accordion page asks for these things:
- The trigger is a button. Use a real
<button>. MDN talks about a<button>‘s click event. It “fires for mouse clicks and when the user presses Space or Enter while the button has focus”. A<div>withonClickgets none of that. - The button says if it is open. The guide says the button “has
aria-expandedset totrue” when its panel is visible, andfalsewhen it isn’t. A screen reader reads it out. - The button points to its panel. The guide says the button “has
aria-controlsset to the ID of the element containing the accordion panel content”. So the panel needs anid. - The button sits inside a heading. The guide also says the button is “the only element inside the heading element”. So put nothing else in the heading, not even a link. Our flexible markup lets the user pick the heading, like the
<h3>above. Radix UI has a part just for this,Accordion.Header, and its code draws an<h3>.
An id must be unique on the whole page. But a component can be used many times. React’s useId page puts it this way: “A component may be rendered more than once on the page—but IDs have to be unique!”
useId is a hook that makes an id. React keeps it the same for that one copy of the component, as long as it stays on the page.
Each item calls useId once. The docs show how to use one id as a start, or prefix, for several related ids. So the trigger’s id is the item’s id plus -trigger, and the panel’s is the item’s id plus -panel. The item puts its id in ItemContext, next to its value.
There is one more change. Before, a closed panel returned null, so it wasn’t on the page at all. Then aria-controls would point to an id that doesn’t exist. So now the panel stays on the page, with the hidden attribute when it is closed. MDN says hidden tells the browser it “should not render the contents of the element”. MDN’s aria-controls page also says it is “valid and easier to program to reference an element that is not visible”.
The panel also gets role="region" and aria-labelledby, which points back to its button. A screen reader can then say which button the panel belongs to. The guide lists these as optional. It also warns against region when more than about 6 panels can be open at the same time. Too many regions make the page harder to move around in.
Here is the full accordion:
import { createContext, useContext, useId, useMemo, useState, type ReactNode } from 'react'
type AccordionState = {
open: string[]
toggle: (value: string) => void
}
type ItemInfo = { value: string; id: string }
const AccordionContext = createContext<AccordionState | null>(null)
const ItemContext = createContext<ItemInfo | null>(null)
function useAccordion() {
const accordion = useContext(AccordionContext)
if (accordion === null) {
throw new Error('Accordion parts must be used inside <Accordion>')
}
return accordion
}
function useItem() {
const item = useContext(ItemContext)
if (item === null) {
throw new Error('Accordion.Trigger and Accordion.Panel must be used inside <Accordion.Item>')
}
return item
}
type AccordionProps = {
type?: 'single' | 'multiple'
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
children: ReactNode
}
function Accordion({ type = 'single', value, defaultValue = [], onValueChange, children }: AccordionProps) {
const [ownValue, setOwnValue] = useState(defaultValue)
const isControlled = value !== undefined
const open = isControlled ? value : ownValue
const state = useMemo(() => {
function toggle(item: string) {
let next: string[]
if (open.includes(item)) {
next = open.filter((v) => v !== item)
} else if (type === 'single') {
next = [item]
} else {
next = [...open, item]
}
if (!isControlled) {
setOwnValue(next)
}
onValueChange?.(next)
}
return { open, toggle }
}, [open, type, isControlled, onValueChange])
return (
<AccordionContext value={state}>
<div>{children}</div>
</AccordionContext>
)
}
function Item({ value, children }: { value: string; children: ReactNode }) {
const id = useId()
const item = useMemo(() => ({ value, id }), [value, id])
return (
<ItemContext value={item}>
<div>{children}</div>
</ItemContext>
)
}
function Trigger({ children }: { children: ReactNode }) {
const { open, toggle } = useAccordion()
const { value, id } = useItem()
return (
<button
id={id + '-trigger'}
aria-expanded={open.includes(value)}
aria-controls={id + '-panel'}
onClick={() => toggle(value)}
>
{children}
</button>
)
}
function Panel({ children }: { children: ReactNode }) {
const { open } = useAccordion()
const { value, id } = useItem()
return (
<div id={id + '-panel'} role="region" aria-labelledby={id + '-trigger'} hidden={!open.includes(value)}>
{children}
</div>
)
}
Accordion.Item = Item
Accordion.Trigger = Trigger
Accordion.Panel = Panel
export default function App() {
return (
<Accordion defaultValue={['shipping']}>
<Accordion.Item value="shipping">
<h3>
<Accordion.Trigger>Shipping</Accordion.Trigger>
</h3>
<Accordion.Panel>We ship in 2 days.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="returns">
<h3>
<Accordion.Trigger>Returns</Accordion.Trigger>
</h3>
<Accordion.Panel>You have 30 days to send it back.</Accordion.Panel>
</Accordion.Item>
</Accordion>
)
}
Run it. It looks like before: Shipping starts open, and opening Returns closes Shipping. This one is uncontrolled. It has no value, only defaultValue={['shipping']}, and type is 'single' by default.
The changes are for screen readers, so you can’t see them. We read the HTML React made, in a fresh page. Before any click, the shipping button was:
<button id="_r_0_-trigger" aria-expanded="true" aria-controls="_r_0_-panel">Shipping</button>
The returns panel was on the page, with hidden="". After a click on “Returns”, the shipping button said aria-expanded="false", and the returns panel had no hidden. React turns aria-expanded={true} into the text "true", and hidden={true} into hidden="".
The ids look strange, like _r_0_. That is what React 19.3 makes, and the number counts up as React makes more ids on the page. Don’t build anything that depends on how they look. Are they stable? We checked with Strict Mode on and off. The ids stayed the same after three clicks, and after the parent rendered again. Two accordions on one page got different ids. React’s docs also warn: “Do not call useId to generate keys in a list. Keys should be generated from your data.”
Keyboard support
The guide’s keys for an accordion are Enter, Space, Tab and Shift+Tab. A real <button> gives all four for free. Enter and Space click it. Tab and Shift+Tab move to the next and the previous thing you can focus.
Some accordions add more. In Radix UI’s, the up and down arrow keys move between triggers. Home and End move to the first and the last. That needs code that moves the focus from one button to another. Part 47 covers keyboard navigation, and Part 39 builds tabs, which need arrow keys.
When a simple props API is better
Compound components are not free.
- More pieces to learn. The user must learn four components and how they nest, not one component and its props.
- Harder to type. TypeScript can’t check that a trigger sits inside an item. You saw that the run-time error is the only guard. Dot notation needs care with types, too.
- More code. Two contexts, two hooks that throw, and a
useMemofor each object value.
So use plain props when:
- the layout is always the same, and nobody will want to change it;
- the content comes as data, like a list of questions from a server, and every section looks the same;
- the component is used in only one place.
Our advice: start with props. Move to compound components when users need to change the layout, add their own tags, or repeat parts. Tabs, menus and accordions are common examples.
Common mistakes
Using a part outside its parent
You saw this above. A trigger or panel outside its item, or outside the accordion, has nothing to read. Throw a clear error from the hook that reads the context. Don’t hide it behind a default value.
Counting on position: index numbers
Some accordions give each item a number for its position, index={0}, index={1}, and keep the open number in state. It looks fine, until someone adds an item:
import { createContext, useContext, useMemo, useState, type ReactNode } from 'react'
type AccordionState = {
openIndex: number | null
setOpenIndex: (index: number | null) => void
}
const AccordionContext = createContext<AccordionState | null>(null)
function Accordion({ children }: { children: ReactNode }) {
const [openIndex, setOpenIndex] = useState<number | null>(null)
const state = useMemo(() => ({ openIndex, setOpenIndex }), [openIndex])
return <AccordionContext value={state}>{children}</AccordionContext>
}
function Item({ index, title, children }: { index: number; title: string; children: ReactNode }) {
const accordion = useContext(AccordionContext)
if (accordion === null) {
throw new Error('Item must be used inside <Accordion>')
}
const { openIndex, setOpenIndex } = accordion
const isOpen = openIndex === index
return (
<div>
<button onClick={() => setOpenIndex(isOpen ? null : index)}>{title}</button>
{isOpen && <p>{children}</p>}
</div>
)
}
export default function App() {
return (
<Accordion>
<Item index={0} title="Payment">We take cards.</Item>
<Item index={0} title="Shipping">We ship in 2 days.</Item>
<Item index={1} title="Returns">You have 30 days to send it back.</Item>
</Accordion>
)
}
{isOpen && <p>...</p>} shows the paragraph only when isOpen is true. Part 7 covered &&.
Someone added “Payment” at the top and gave it index={0}. They forgot to change the numbers below it. Run it and click “Payment”. Two sections open: Payment and Shipping. Both are number 0.
An index says where an item is, and that changes when you add, remove or move items. A name like "shipping" says what the item is, and it stays the same. That is why our Item takes a value.
Some accordions count the children for you, with React’s Children API. That breaks too. React’s Children page warns: “There is no way to get the rendered output of an inner component like <MoreRows />“. Children sees a component like ReturnsItem above as one child. It can’t reach the item inside it to give that item an index. Part 44 is about the Children API. Context has neither problem.
A new context value on every render
Here is the accordion from “Try this first” without useMemo. The value is written as {{ openValue, setOpenValue }}, right in the JSX. The items live in a FaqItems component wrapped in memo (Part 20), and Trigger logs each render:
import { createContext, memo, useContext, useState, type ReactNode } from 'react'
type AccordionState = {
openValue: string | null
setOpenValue: (value: string | null) => void
}
const AccordionContext = createContext<AccordionState | null>(null)
const ItemContext = createContext<string | null>(null)
function useAccordion() {
const accordion = useContext(AccordionContext)
if (accordion === null) {
throw new Error('Accordion parts must be used inside <Accordion>')
}
return accordion
}
function useItemValue() {
const value = useContext(ItemContext)
if (value === null) {
throw new Error('Accordion.Trigger and Accordion.Panel must be used inside <Accordion.Item>')
}
return value
}
function Accordion({ children }: { children: ReactNode }) {
const [openValue, setOpenValue] = useState<string | null>(null)
return (
<AccordionContext value={{ openValue, setOpenValue }}>
<div>{children}</div>
</AccordionContext>
)
}
function Item({ value, children }: { value: string; children: ReactNode }) {
return (
<ItemContext value={value}>
<div>{children}</div>
</ItemContext>
)
}
function Trigger({ children }: { children: ReactNode }) {
const { openValue, setOpenValue } = useAccordion()
const value = useItemValue()
const isOpen = openValue === value
console.log('Trigger renders')
return <button onClick={() => setOpenValue(isOpen ? null : value)}>{children}</button>
}
function Panel({ children }: { children: ReactNode }) {
const { openValue } = useAccordion()
const value = useItemValue()
if (openValue !== value) {
return null
}
return <div>{children}</div>
}
Accordion.Item = Item
Accordion.Trigger = Trigger
Accordion.Panel = Panel
const FaqItems = memo(function FaqItems() {
return (
<>
<Accordion.Item value="shipping">
<Accordion.Trigger>Shipping</Accordion.Trigger>
<Accordion.Panel>We ship in 2 days.</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="returns">
<Accordion.Trigger>Returns</Accordion.Trigger>
<Accordion.Panel>You have 30 days to send it back.</Accordion.Panel>
</Accordion.Item>
</>
)
})
export default function App() {
const [clicks, setClicks] = useState(0)
return (
<div>
<button onClick={() => setClicks(clicks + 1)}>Clicks: {clicks}</button>
<Accordion>
<FaqItems />
</Accordion>
</div>
)
}
Trigger renders
Trigger renders
Trigger renders
Trigger renders
Trigger renders
Trigger renders
Trigger renders
Trigger renders
Run it and click “Clicks” once. The Console shows “Trigger renders” 8 times. Each line comes twice, because Strict Mode renders each component twice in development, as Part 2 showed. So the first 4 lines are the first render of the two triggers. The next 4 lines come from the click.
The click has nothing to do with the accordion. memo stopped FaqItems from rendering. But Accordion rendered, because its parent App did. It made a new { openValue, setOpenValue } object, so every trigger rendered again.
The fix is the useMemo line from “Try this first”:
const state = useMemo(() => ({ openValue, setOpenValue }), [openValue])
We measured both, with Strict Mode off. Three clicks on “Clicks” rendered the triggers 6 times without useMemo: 2 triggers, 3 times each. With useMemo, 0 times.
The fix works here only because of memo on FaqItems. We took memo away and measured again. Then three clicks rendered the triggers 6 times, with useMemo or without it. FaqItems rendered because App did, and so did everything inside it. Part 23 found the same limit. Part 23 showed that the React Compiler can keep such an object for you. The playground doesn’t run it.
A <div> as the trigger
function Trigger({ onOpen }: { onOpen: () => void }) {
return <div onClick={onOpen}>Shipping</div>
}
A mouse can click it. But a keyboard user can’t reach it with Tab, and Enter and Space do nothing. A screen reader doesn’t call it a button. Use <button>, as the full accordion above does.
Practice
Press Edit on the examples above and try these.
- In “Try this first”, add a third item, “Payment”. Its panel text is
We take cards.Does only one item open at a time? - In “Try this first”, add
console.log('Panel renders', value)toPanel, just before theif. Run it. How many lines does the Console show? Then click “Shipping”. How many lines does that click add, and why? - In the full accordion, change
defaultValue={['shipping']}todefaultValue={['shipping', 'returns']}and addtype="multiple". What do you see when it loads? Then click “Shipping”. - In the “Open all” example, change
type="multiple"totype="single". Click “Open all”. How many items are open? Why?
Answers
- Copy the shipping item, and put the copy after the returns item. In the copy, change
value="shipping"tovalue="payment". Change the trigger text toPayment, and the panel text toWe take cards.Run it. Click “Payment”, then “Shipping”. Payment closes when Shipping opens. The accordion’s code didn’t change. - On load, 4 lines: “Panel renders shipping” twice, then “Panel renders returns” twice. There are two panels, and Strict Mode renders each twice. The click adds 4 more lines in the same order. Both panels read the context, and its value changed, so both render again. That includes the returns panel, which stays closed.
- Both items are open when it loads. Click “Shipping” and it closes, and Returns stays open. In the HTML, the shipping panel gets
hidden=""and its button getsaria-expanded="false". - Two.
Appsetopento['shipping', 'returns'], and a controlled accordion shows whatvaluesays.typeonly changes whattoggleasks for. So the parent can break the “one at a time” rule. Click “Returns” now, and it closes, leaving only Shipping open.
Interview questions
Try to answer each one out loud before you open the answer.
What are compound components? Give an example.
A group of components that work together. The parent holds the shared state, and the parts read it themselves. The user writes the parts as tags, in any layout. HTML’s <select> and <option> work this way. In React, <Accordion>, <Accordion.Item>, <Accordion.Trigger> and <Accordion.Panel> are a common example. So are tabs and menus.
A strong answer names the problem it solves. One component with a prop for every layout choice gets a very long list of props.
How do the parts share state without props?
Through context. The parent gives its state with a provider, and each part reads it with a hook like useAccordion(). An item can give its own name through a second context. Then its trigger and panel know which item they are in. Context reaches through any components in between. So the user can wrap parts in headings, or put an item in a component of its own.
A strong answer mentions the older way: Children.map and cloneElement to add props to each child. React’s docs warn that this code breaks easily. It can’t see inside a child component, and it breaks when the user wraps a part.
How do you make a compound component work both controlled and uncontrolled?
Take value and onValueChange for the controlled way, and defaultValue for the uncontrolled way, like an <input>. Keep your own state, started from defaultValue. Then pick one: const open = value !== undefined ? value : ownValue. When something changes, save it in your own state only if uncontrolled, and always call onValueChange.
A strong answer adds that defaultValue is read only once. It also says a component shouldn’t switch between the two ways while it is on the page.
What happens if someone uses <Accordion.Panel> outside <Accordion>? Can TypeScript catch it?
Give the context a null default, and read it with a hook that checks for null. Then the app throws a clear error, like “Accordion parts must be used inside <Accordion>“. TypeScript can’t catch it. It checks a component’s props, not which tags it sits inside. So the run-time error is the only guard.
A strong answer says why a default value is worse: the part would show up and quietly do nothing.
How do you type Accordion.Item in TypeScript?
Write Accordion as a plain function, and set the parts on it: Accordion.Item = Item. TypeScript adds those properties to the function’s type. If you give it the type FC<Props>, TypeScript says Property 'Item' does not exist. Then use Object.assign(AccordionRoot, { Item, Trigger, Panel }), which returns a type with all of them.
A strong candidate adds the costs of the dots. A build tool can’t leave out a part you don’t use. And a Server Component can’t read Accordion.Item from a 'use client' file: React throws “You cannot dot into a client module from a server component”. Separate exports avoid both, which is how Radix UI does it.
What does an accessible accordion need?
A real <button> as each trigger, so Enter, Space and Tab work. aria-expanded on the button, true or false. aria-controls on the button, set to the panel’s id. And the button inside a heading, with nothing else in that heading. The ids come from useId, so they are unique even when the accordion is used twice. Keep closed panels on the page with hidden, so aria-controls points to something real.
A strong answer names the source: the W3C’s ARIA Authoring Practices Guide. It also says role="region" on panels is optional. It is best left out when more than about 6 panels can be open at once. And it says some libraries add arrow keys that move the focus.
When would you not use compound components?
When the layout is fixed and nobody needs to change it. When the content is plain data and every section looks the same. When the component is used in one place. Then a props API is shorter, easier to learn and fully checked by TypeScript. Compound components cost more pieces, two contexts, and a run-time check that TypeScript can’t do.
Sources
<select>, MDN: “Each menu option is defined by an<option>element nested inside the<select>.”- Children, react.dev: giving the user a
Rowcomponent to wrap each row, the warning thatChildrencode breaks easily, and that it can’t see inside an inner component. - createContext and useContext, react.dev: the provider, the nearest provider, and
Object.isfor the value. - useId, react.dev: unique ids, one id as a prefix for several, and not for list keys.
- Accordion Pattern, W3C ARIA Authoring Practices Guide: the button,
aria-expanded,aria-controls, the heading that holds only the button, the optional region and its limit, and the keys. - Tabs Pattern, W3C ARIA Authoring Practices Guide: arrow keys move between tabs.
- button role, MDN: a
<button>fires its click for Space and Enter. - aria-controls and hidden, MDN: pointing to a panel that is not visible, and what
hiddendoes. - Accordion and Introduction, Radix UI: the parts,
type,value,defaultValue,onValueChange,collapsibleand the keys; the@radix-ui/react-accordion1.2.21 andradix-ui1.7.0 code on npm: separate exports,import * as Accordion, andAccordion.Headerdrawing an<h3>. - The render counts, the ids, the HTML, the TypeScript errors, the Server Components error (from
react-server-dom-webpack19.3.0) and the build test (rolldown1.2.12, from Vite 8.3.3) come from running them for this post, with React 19.3.0 and TypeScript 7.0.2. - This part follows the Compound Components kata in react-katas.