AcademyVue
Compound Components
Tabs, Tab and TabPanel coordinate through one shared provide/inject context — each part only has to talk to that context, never to its siblings.
2 min readUpdated 3 exercises
Several components, one piece of shared state
A compound component is a family — Tabs, Tab, TabPanel — designed to be used together, coordinating through state one of them owns and the others read or write via provide/inject:
<Tabs default-tab="profile">
<Tab id="profile">Profile</Tab>
<Tab id="settings">Settings</Tab>
<TabPanel id="profile">Profile content</TabPanel>
<TabPanel id="settings">Settings content</TabPanel>
</Tabs>Nothing here passes activeTab as a prop down through every layer, and Tab/TabPanel never talk to each other directly — both only ever talk to the shared context Tabs provides.
The parent owns and provides the shared state
const TabsKey: InjectionKey<{
activeTab: Ref<string>
setActiveTab: (id: string) => void
}> = Symbol('tabs')
const Tabs = defineComponent({
props: { defaultTab: { type: String, required: true } },
setup(props, { slots }) {
const activeTab = ref(props.defaultTab)
provide(TabsKey, { activeTab, setActiveTab: (id) => { activeTab.value = id } })
return () => h('div', slots.default ? slots.default() : null)
},
})Tabs itself renders almost nothing — a wrapping <div> around whatever its children are. Its entire job is owning activeTab and making it (and a way to change it) available to anything nested inside.
Each child injects the context and does its own small part
const Tab = defineComponent({
props: { id: { type: String, required: true } },
setup(props, { slots }) {
const ctx = inject(TabsKey)!
return () =>
h(
'button',
{
role: 'tab',
'aria-selected': ctx.activeTab.value === props.id,
onClick: () => ctx.setActiveTab(props.id),
},
slots.default ? slots.default() : null,
)
},
})
const TabPanel = defineComponent({
props: { id: { type: String, required: true } },
setup(props, { slots }) {
const ctx = inject(TabsKey)!
return () => (ctx.activeTab.value === props.id ? h('div', { role: 'tabpanel' }, slots.default ? slots.default() : null) : null)
},
})Tab reads the shared activeTab to decide its own aria-selected, and calls setActiveTab on click. TabPanel reads the same activeTab to decide whether to render at all. Neither knows the other exists — a Tab that forgets to inject the context is not broken in some subtle way, it is simply an inert button: no onClick, no aria-selected, nothing wired to the state it was supposed to coordinate through.
Why this earns its complexity over one big Tabs component
A single component owning tab buttons, panels and the switching logic together forces every consumer to accept its exact markup and structure. Splitting it into Tabs/Tab/TabPanel lets a consumer control the DOM structure, styling, and even the order of tabs versus panels, while the state coordination — which one is active — stays centralized and correct regardless of how the pieces are arranged in the template.
What interviewers check
Whether the injected context gets named as the coordination mechanism, not "they just work together somehow" — a compound component set that cannot name what state is shared and how each part reads or writes it has memorized the pattern's shape without understanding what makes it work.
Now practice it
2 quizzes · 1 drillReading this page is the cheap half. These are the exercises that make you use it.
- Compound ComponentsProA Tab that never injects the shared Tabs context — an inert button until it is wired up, exactly as this page describes.DrillSolve · HardDrillSolve · Hard
- Options API · Vue ExpertProCompound components sit at the deep end of real-world Vue architecture, which is exactly this set's level.QuizTake · HardQuizTake · Hard
- Composition API · watch & ComposablesEvery part of this pattern is Composition API fundamentals (provide/inject, ref) — this set checks that base.QuizTake · MediumQuizTake · Medium
Read next
Provide / Inject: Skipping the Prop Chain
provide() and inject() pass data straight to any descendant, no matter how deep — but only stay reactive when what you provide is itself reactive.
2 min
Teleport & Suspense
Teleport moves a component's DOM without moving its logical position; Suspense gives an async component a fallback while it loads.
2 min