Designing Component APIs: Stable Interfaces for Teams in Vue 3
AI generated
<v/>
{ }
Vue 3 · Component API · Props · Slots · TypeScript
Designing Component APIs
stable interfaces for growing teams in Vue 3

A poorly designed Component API in a shared component library creates extra work for every team member who uses it. Props without default values, Emits without type contracts, missing slots for composition points and an undocumented Expose interface all make components hard to use. With clear conventions and TypeScript, Component APIs emerge that are stable, extensible and backwards-compatible.

22 min read Props · Emits · Slots · defineExpose · API versioning Vue 3.4+ · TypeScript · Vite · Vitest

1. Why Component API design is critical for teams

A Component API in Vue 3 is the contract between a component and everyone who uses it. In a team with several developers, dozens of places in the application use the same base component. If the Component API is poorly designed, props with unclear names, emits without documentation, missing slots for customization points, then the damage is multiplied across every place that uses it. Every breaking change in a shared base component creates work everywhere it is consumed. Good Component API design therefore pays for itself faster than it would in a solo project.

Vue 3 and TypeScript together offer the best toolset for stable Component APIs available in the JavaScript ecosystem. defineProps<Props>() makes props fully typed and checkable by TypeScript. defineEmits<Emits>() makes event contracts type-safe. Slot types become documentable via defineSlots. defineExpose() explicitly controls which imperative interface is visible to the outside. These four mechanisms together produce a Component API design that lets the TypeScript compiler catch errors when a component is used, at compile time, not at runtime and not through code review.

2. Designing Props properly: types, defaults and validation

The foundation of every Component API design in Vue 3 is props. Props are the primary communication channel from parent to child component and should be designed so that the most common use cases require no props at all, while the rarest use cases can be covered by additional props. The principle: any prop that has a sensible default value should have one. A ButtonComponent intended for primary buttons should have variant default to 'primary', the caller only needs to pass something when a different variant is wanted.

Prop naming follows clear conventions in Component API design: boolean props start without an is prefix when the name is already unambiguous on its own (disabled instead of isDisabled), but with an is prefix when the context would otherwise be unclear (isLoading, isExpanded). Props for callback functions, the Vue 3 alternative to Emits in certain scenarios, start with on (onConfirm, onClose) and are optional functions. Passing complex configuration objects as a single prop instead of many flat props is the right Component API pattern for highly configurable components such as tables or charts, :columns="columnDef" instead of ten separate column props.


<!-- DataTable.vue - Well-designed component API example -->
<script setup lang="ts">
// Type imports - clearly documented contract
import type { ColumnDef, SortState, PaginationState } from '@/types'

interface Props {
  // Required: core data
  rows: Record<string, unknown>[]
  columns: ColumnDef[]

  // Optional with defaults: common configuration
  loading?: boolean
  selectable?: boolean
  pageSize?: number
  emptyText?: string

  // Optional without defaults: advanced features
  sort?: SortState
  pagination?: PaginationState
  rowClass?: (row: Record<string, unknown>) => string
}

const props = withDefaults(defineProps<Props>(), {
  loading: false,
  selectable: false,
  pageSize: 25,
  emptyText: 'No entries available',
})

// Typed emits - the event contract
const emit = defineEmits<{
  // Named tuple syntax: event name → argument types
  'sort-change': [sort: SortState]
  'page-change': [page: number]
  'selection-change': [selectedIds: (string | number)[]]
  'row-click': [row: Record<string, unknown>, event: MouseEvent]
}>()

// Typed slots - documents what slot props are available
const slots = defineSlots<{
  // Default slot receives the row and column context
  cell?: (props: { row: Record<string, unknown>; column: ColumnDef; value: unknown }) => void
  // Named slots for custom header and footer
  'header-extra'?: () => void
  'empty-state'?: () => void
  'footer'?: (props: { total: number; page: number }) => void
}>()
</script>

3. Defining Emits as type-safe contracts

Emits are the return channel of a Vue 3 Component API, they define which events a component sends outward and what data those events carry. In Vue 3 with TypeScript you use the typed defineEmits<{...}>() syntax with named tuple types that exactly define the argument list of each event. That gives the TypeScript compiler the information it needs to report errors on v-on:event-name bindings in the parent component when the wrong types are passed. Without typing, emits are invisible to the compiler, a common source of hard-to-find runtime bugs in shared component libraries.

The Component API design principle for emits: name events after what happened, not after what the parent component is supposed to do. 'item-deleted' instead of 'refresh-list', 'filter-changed' instead of 'reload-data'. That makes components universally reusable, the parent component decides how to react to an event; the child component only reports what happened. For events with complex payloads, a typed interface as the argument is better than many separate parameters, 'form-submitted': [data: FormData] instead of 'form-submitted': [name: string, email: string, message: string], because the latter turns extending the form data into a breaking change.

4. Slot APIs: choosing the right slot strategy

Slots are the most powerful composition tool in Vue 3 Component API design, and the one most often underused or misused. A default slot suits components that wrap arbitrary content: BaseCard, BaseModal, BaseSection. Named slots suit components with clearly defined content areas: header, body, footer for a dialog, prefix and suffix for an input. Scoped slots are the most powerful pattern, they pass data from the child component to the slot content in the parent component, enabling fully flexible rendering while keeping logic cleanly separated.

The scoped-slot pattern for a data table is a good example: the table component takes care of sorting, pagination, selection and layout. How a single cell is rendered is left to the caller through a scoped slot that passes the row, the column definition and the cell value. This is the opposite of a render-prop pattern from React, it is native to Vue and typeable with defineSlots. Important for Component API design: slots should be documented, which slots exist, which scoped slot props are available, and what default rendering appears when no slot is used. In a component library, this documentation is just as important as prop documentation.


<!-- Accordion.vue - Component family using provide/inject for coordination -->
<script setup lang="ts">
import { ref, provide } from 'vue'

interface Props {
  // Allow multiple items open simultaneously or only one
  multiple?: boolean
  // Default open items by their value
  defaultOpen?: string[]
}

const props = withDefaults(defineProps<Props>(), {
  multiple: false,
  defaultOpen: () => [],
})

const emit = defineEmits<{
  'change': [openItems: string[]]
}>()

// Internal state - not exposed, managed by coordination
const openItems = ref<string[]>(props.defaultOpen)

function toggle(itemValue: string) {
  if (props.multiple) {
    const idx = openItems.value.indexOf(itemValue)
    if (idx >= 0) {
      openItems.value.splice(idx, 1)
    } else {
      openItems.value.push(itemValue)
    }
  } else {
    openItems.value = openItems.value.includes(itemValue) ? [] : [itemValue]
  }
  emit('change', [...openItems.value])
}

function isOpen(itemValue: string) {
  return openItems.value.includes(itemValue)
}

// Provide coordination API to child AccordionItem components
provide('accordion', { isOpen, toggle })

// Public API: programmatic control for parent components
defineExpose({ openAll, closeAll, openItem: toggle })

function openAll() {
  // Only makes sense with multiple=true
  emit('change', openItems.value)
}

function closeAll() {
  openItems.value = []
  emit('change', [])
}
</script>

<template>
  <!-- Simple structural wrapper - no presentational logic -->
  <div role="presentation">
    <slot />
  </div>
</template>

5. defineExpose: the public imperative interface

In Vue 3 with <script setup>, nothing about a component is accessible from the outside by default, no state, no methods. That is a deliberate design decision that prevents accidental coupling. defineExpose() explicitly opens up certain methods or ref values for parent access via template refs. The Component API design principle: expose sparingly, only expose what genuinely needs to be controlled imperatively from the outside. A modal exposes open() and close(). A form exposes validate() and reset(). A video player exposes play(), pause() and seek(timestamp). State such as isOpen or currentTime should instead be communicated as an emit, not made directly readable.

TypeScript typing for exposed interfaces is achieved by declaring an explicit type: export type ModalInstance = { open: () => void; close: () => void }. In the parent context you then use useTemplateRef<ModalInstance>('modal'), which gives full autocomplete and type checking when calling modal.value?.open(). This practice is especially important in component libraries that are published as npm packages: external consumers see the exposed interface as part of the public Component API and rely on it. Changes to the expose interface are breaking changes and must be communicated as such.

6. Provide/Inject for component families

Provide/Inject is the Vue 3 Component API pattern for component families: groups of related components that need to coordinate with each other without props being passed through many intermediate layers. The textbook example is an accordion with an Accordion container and AccordionItem children: the container holds the open/closed state and exposes a coordination API via provide(). Each AccordionItem calls inject() to obtain isOpen and toggle, without props, without event bubbling through intermediate elements.

The type-safe Provide/Inject pattern in Vue 3 uses typed injection keys: const AccordionKey = Symbol() as InjectionKey<AccordionContext>. The symbol guarantees the key is unique, and the TypeScript type guarantees that inject(AccordionKey) always returns AccordionContext | undefined instead of just unknown. A guard const ctx = inject(AccordionKey); if (!ctx) throw new Error('AccordionItem must be inside Accordion') gives a clear error message if someone uses AccordionItem outside of its container. This Component API pattern makes component families robust and their usage rules self-documenting.

7. API versioning and breaking-change management

In a component library shared across a team, Component API versioning is an underrated topic. When a component is used in twenty places in the application, renaming a prop is a breaking change that requires twenty simultaneous edits. The Component API design principle for backwards compatibility: add new props as optional, never remove old props immediately. For renames there is a deprecation period during which the old prop is still accepted and emits a runtime warning in the development environment when used. Only in the next major version is the deprecated prop actually removed.

Semantic versioning is the most reliable way to communicate breaking changes for internal component libraries. Every breaking change means a new major version. New features without breaking changes mean minor versions. Bug fixes are patch versions. With npm workspaces or Vite's library mode as an npm package, a component library can be versioned and pulled in as a dependency by other parts of the monorepo. That allows different parts of the application to sit on different versions of the component library while migration to newer versions happens incrementally. This practice makes Component API evolution plannable instead of chaotic.


// types/injection-keys.ts - Typed injection keys for component families
import type { InjectionKey, Ref } from 'vue'

// AccordionContext - shared state between Accordion and AccordionItem
export interface AccordionContext {
  isOpen: (value: string) => boolean
  toggle: (value: string) => void
  multiple: boolean
}

// Type-safe injection key - Symbol ensures uniqueness
export const AccordionKey: InjectionKey<AccordionContext> = Symbol('Accordion')

// TabsContext - shared state between Tabs and Tab
export interface TabsContext {
  activeTab: Ref<string>
  setActiveTab: (value: string) => void
  orientation: 'horizontal' | 'vertical'
}

export const TabsKey: InjectionKey<TabsContext> = Symbol('Tabs')

// Usage in AccordionItem.vue:
// const ctx = inject(AccordionKey)
// if (!ctx) throw new Error('AccordionItem must be a child of Accordion')
// const isOpen = computed(() => ctx.isOpen(props.value))

// Usage pattern with provide in Accordion.vue:
// provide(AccordionKey, { isOpen, toggle, multiple: props.multiple })

8. Common mistakes in Component API design

The most common mistake in Component API design is the God Component: a component that gets extended with ever more props for ever more use cases until its API has thirty props, none of them easy to understand. The symptom is props that contradict each other or only make sense in certain combinations. The solution is composition over configuration: instead of a single Input component with twenty props, build specialized variants such as TextInput, NumberInput, CurrencyInput that all share a common BaseInput composable. Each of them gets a small, understandable Component API instead of one shared, unintelligible one.

A second widespread mistake is missing slot design. If a component provides no slots, every customization request needs a new prop, which leads straight to a God Component. Proper Component API design treats slots as extension points: where is it plausible that someone will want to insert different content? Headers, footers, empty states, action areas, these are typical slot points. A third mistake: no clear ownership of state. When it is unclear whether the parent component or the child component owns the state of an interaction, inconsistent behavior results. The Component API design pattern for uncontrolled/controlled components: if v-model is not used, the component holds its own state (uncontrolled). If v-model is used, the parent component is the single source of truth (controlled).

9. Component API patterns compared side by side

In Component API design for Vue 3 teams, every situation has a better pattern and a worse one. The following table shows the most common decisions and their consequences.

Situation Fragile pattern Stable Component API pattern Benefit
Component customization 30 props for every use case Scoped slots for flexibility No God Component, extensible without breaking changes
Component family Props through 5 layers provide/inject with InjectionKey Type-safe, direct, no prop drilling
Imperative interface Ref to internal methods defineExpose with type Explicit public API, no internal leakage
Breaking change Rename prop immediately Deprecation + runtime warning Incremental migration, no big-bang changes
Form integration Direct state in child component defineModel() for v-model Parent holds state, child renders, clear ownership

The table shows: good Component API design is not a single technique but a set of conventions that must be applied consistently. Teams that document these conventions explicitly, in a CONTRIBUTING.md or an Architecture Decision Record, have fewer discussions during code review and more consistent component libraries.

Mironsoft

Vue 3 component libraries, API design and frontend architecture for teams

A component library your team can rely on?

We design and build Vue 3 component libraries with stable, type-safe Component APIs, prop conventions, slot strategy, defineExpose interfaces and a versioning strategy for growing teams.

API audit

Review existing component libraries for prop consistency, breaking-change risk and slot strategy

Library build-out

Vue 3 component library with Vite, Vitest, TypeScript and complete API documentation

Team guidelines

Document Component API conventions, naming guidelines and versioning strategy for your team

10. Summary

Good Component API design in Vue 3 is an investment that pays off over the entire lifetime of a component library. Prop typing with defineProps<Props>() and withDefaults gives every prop a clear contract and default value. Emit contracts with defineEmits<{...}>() turn events into TypeScript-checked interfaces. Scoped slots with defineSlots allow customization without God Component growth. defineExpose() explicitly controls which imperative interface is accessible from the outside. Provide/Inject with typed injection keys coordinates component families without prop drilling.

The biggest lever is consistency. A component library that follows solid Component API design principles for its first components but reaches for quick fixes under time pressure for the next ten is inconsistent and hard to use. Teams that document explicit conventions for prop naming, slot design, event naming and versioning, and enforce them in code review, end up with component libraries that grow with the team instead of slowing development down over time.

Designing Component APIs, the essentials at a glance

Props & Emits

defineProps<Props>() with withDefaults. Emits as named tuple types. Name events after what happened, not what the parent should do.

Slots for flexibility

Scoped slots as extension points instead of a growing prop list. defineSlots for TypeScript documentation of slot contracts.

Expose & Inject

defineExpose sparingly, only genuine imperative APIs. Typed injection keys for component families without prop drilling.

Versioning

Semantic versioning, deprecation period with runtime warning, no immediate renaming. Announce breaking changes, do not spring them on people.

11. FAQ: Designing Component APIs in Vue 3

1What makes a good Component API?
Minimal, typed, with default values, extensible through slots. Common use cases without prop configuration, rare ones through optional props.
2Slots vs. props for configuration?
Props for values (string, number, boolean). Slots when content or layout needs to be customizable. HTML/JSX in a prop? Use a slot instead.
3When to use defineExpose?
Only for genuine imperative APIs: Modal.open(), Form.validate(). Always communicate state via emits, not via expose refs.
4Preventing prop drilling?
provide()/inject() with typed InjectionKey symbols. For global state, use Pinia instead of Provide/Inject.
5Managing breaking changes?
Semantic versioning. Keep deprecated props with a runtime warning. Carry out the breaking change only in a major version.
6Controlled vs. uncontrolled?
Uncontrolled: state held internally. Controlled: state held by the parent via v-model. defineModel() for the controlled case.
7Typing scoped slots?
defineSlots<{ name?: (props: SlotProps) => void }>() documents slot contracts. TypeScript then checks v-slot usage in the parent.
8When to split a component?
More than 10 to 12 props, mutually exclusive prop combinations, a template over 100 lines, three different concepts in one component.
9Testing Component APIs with Vitest?
mount() with props, check emits on interaction, render slots with custom content, call defineExpose methods via wrapper.vm.
10Documenting APIs across a team?
Storybook with auto-generation, JSDoc comments, CONTRIBUTING.md with conventions, ADRs for larger decisions. TypeScript types as the primary documentation.