codejump

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

Waiting at the end of this page.

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:

vue
<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

TypeScript
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

TypeScript
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 drill

Reading this page is the cheap half. These are the exercises that make you use it.