← Blog
16 Aug 2026VueVue componentscomponent architecturedesign systemsreusable UI

How to Organize Vue Application Components for Reusable UI Systems

Organize Vue application components into reusable UI systems with clear layers, explicit APIs, composables, tests, and scalable app shells.

How to Organize Vue Application Components for Reusable UI Systems

Vue application components become easier to extend when we organize them by responsibility, not by the page where they first appeared. The practical model is simple: keep reusable UI primitives independent, compose them into shared patterns, keep domain rules in feature components, and let pages assemble the result.

In this guide, we will build that structure step by step. By the end, you will have a testable component hierarchy that can grow from a button or form field to a responsive application shell without turning every component into a dependency hub.

Table of contents

Before you start

You need a Vue 3 project using Single-File Components, a working test runner, and an agreed import alias such as @/. We also recommend TypeScript for public component props and emitted events, although the folder structure works without it.

For the examples below, create a src/components directory and choose one screen that has repeated UI, such as an account list, dashboard, or settings form. Do not start by migrating the whole application.

1. Draw the component boundary before creating files

Start with a screen and identify what changes for each instance.

A component belongs in a reusable layer when it has a stable responsibility and can receive its variation through an explicit API. A customer row, for example, may be a feature component if it knows about account health. A card, status pill, button, dialog, or field should not know about that business concept.

Use this quick decision test:

  1. Does it render a repeatable interaction or visual pattern? Make it a candidate primitive or shared pattern.
  2. Does it contain domain language, API calls, or feature-specific permissions? Keep it in the feature layer.
  3. Is it only arranging feature components for one route? Keep it in the page or route layer.

Expected result: each component has one obvious owner and a short sentence that explains its job.

Troubleshooting: if your proposed component name contains two unrelated nouns, such as CustomerTableDialog, you are probably combining responsibilities. Extract the generic dialog behavior and let the customer feature compose it.

Reusable interface primitives being assembled into a larger Vue feature screen

2. Create four predictable component layers

We recommend a shallow, responsibility-first structure:

src/
  components/
    ui/             # generic primitives
    patterns/       # reusable compositions
    features/       # domain-specific UI
  composables/      # reusable stateful logic
  pages/            # route-level composition

ui/: application primitives

Put generic application components here: AppButton, AppDialog, AppCard, AppField, AppTabs, and AppToast. Their public APIs should describe appearance, behavior, and accessibility needs, not product entities.

<!-- components/ui/AppButton.vue -->
<script setup lang="ts">
defineProps<{
  variant?: 'primary' | 'secondary' | 'ghost'
  loading?: boolean
  disabled?: boolean
}>()

const emit = defineEmits<{
  click: [event: MouseEvent]
}>()
</script>

<template>
  <button
    class="app-button"
    :disabled="disabled || loading"
    @click="emit('click', $event)"
  >
    <span v-if="loading" aria-hidden="true">Loading</span>
    <slot />
  </button>
</template>

patterns/: common compositions

Patterns combine primitives into a reusable UI arrangement. Examples include FilterBar, ConfirmDialog, EmptyState, and DataTableToolbar. They can use slots when callers need to supply content without changing the pattern’s implementation.

features/: product language and workflows

Feature components own concepts such as CustomerHealthPanel, ProjectMemberPicker, or InvoiceApprovalForm. They can use composables and call feature services, but should consume primitives and patterns rather than duplicate them.

pages/: route composition only

A page should mostly select a layout, load route-level data, and compose features. If the page contains low-level keyboard handling, dialog state, and row rendering, move that work down to the appropriate layer.

Expected result: a new team member can predict where a component lives from its responsibility.

Troubleshooting: do not create a shared/ folder that becomes a second junk drawer. If a component is generic, it belongs in ui/ or patterns/. If it speaks the product’s domain, it belongs in features/.

DOM Studio follows a related layered model with headless elements, Vue wrappers, forms, and application blocks. Its component library is a useful reference for separating generic interface building blocks from product-specific screens.

3. Make props, events, and slots your component contract

Vue components are reusable when their boundaries are intentional. We treat props as inputs, events as outward notifications, and slots as caller-provided content.

For example, a reusable card should accept layout options and expose regions for content. It should not fetch an account record itself or decide what a customer health score means.

<!-- components/patterns/PanelCard.vue -->
<script setup lang="ts">
withDefaults(defineProps<{
  padded?: boolean
}>(), {
  padded: true,
})
</script>

<template>
  <section :class="['panel-card', { 'panel-card--padded': padded }]">
    <header v-if="$slots.header" class="panel-card__header">
      <slot name="header" />
    </header>

    <div class="panel-card__body">
      <slot />
    </div>

    <footer v-if="$slots.actions" class="panel-card__actions">
      <slot name="actions" />
    </footer>
  </section>
</template>

Use this contract checklist before you publish a component:

  • Name props after what the caller controls, such as size, selected, or loading.
  • Emit an event when a user intent occurs, such as save, close, or update:modelValue.
  • Use a slot when callers need to supply markup or another component.
  • Keep API calls, route navigation, and store mutations outside generic UI components.

Expected result: the component can be shown in a story, playground, or isolated test with mock data.

Troubleshooting: if a prop list grows to include showHeader, headerText, headerIcon, headerActionLabel, and several callback props, replace the configuration cluster with one or more slots.

DOM Studio’s component playground demonstrates why inspectable props and schemas matter: they make the component contract easier to understand, test, and edit without reading every implementation detail.

4. Move reusable behavior into composables, not visual components

A component should own rendering and interaction wiring. Reusable stateful behavior belongs in a composable when it can serve more than one component or needs focused tests.

Here is a simple example for async saving:

// composables/useSaveAction.ts
import { ref } from 'vue'

export function useSaveAction<T>(save: (value: T) => Promise<void>) {
  const isSaving = ref(false)
  const error = ref<Error | null>(null)

  async function run(value: T) {
    isSaving.value = true
    error.value = null

    try {
      await save(value)
    } catch (caught) {
      error.value = caught instanceof Error ? caught : new Error('Save failed')
      throw error.value
    } finally {
      isSaving.value = false
    }
  }

  return { isSaving, error, run }
}

Then a feature component decides what is being saved, while a primitive button only receives loading and emits a click.

<script setup lang="ts">
import AppButton from '@/components/ui/AppButton.vue'
import { useSaveAction } from '@/composables/useSaveAction'

const { isSaving, error, run } = useSaveAction(saveCustomer)
</script>

<template>
  <AppButton :loading="isSaving" @click="run(customerDraft)">
    Save customer
  </AppButton>
  <p v-if="error" role="alert">{{ error.message }}</p>
</template>

Expected result: the same async state pattern can support a settings form, bulk action, or onboarding step without duplicating it.

Troubleshooting: do not move template markup into a composable. If the reusable unit renders a visible interface, it is usually a component. If it coordinates reactive state and functions, it is usually a composable.

Screenshot of getdom.studio

5. Compose features into application layouts

Once primitives and feature components have clean boundaries, use layouts to create product surfaces. A dashboard page may compose navigation, filters, metrics, a table, and a detail panel, while each piece keeps its own responsibility.

<!-- pages/CustomerHealthPage.vue -->
<script setup lang="ts">
import AppShell from '@/components/patterns/AppShell.vue'
import CustomerHealthFilters from '@/components/features/customer-health/CustomerHealthFilters.vue'
import CustomerHealthSummary from '@/components/features/customer-health/CustomerHealthSummary.vue'
import CustomerHealthTable from '@/components/features/customer-health/CustomerHealthTable.vue'
</script>

<template>
  <AppShell>
    <template #sidebar>
      <WorkspaceNavigation />
    </template>

    <CustomerHealthFilters />
    <CustomerHealthSummary />
    <CustomerHealthTable />
  </AppShell>
</template>

Keep cross-cutting context narrow. For a deeply nested component family, provide and inject can reduce prop drilling for a local concern such as a form field group or tab registry. Do not use them as a hidden replacement for every explicit prop. If many unrelated features depend on the same data, evaluate a dedicated store or service boundary instead.

Expected result: changing the customer-health feature does not force changes to the base card, button, or shell components.

Troubleshooting: if your AppShell accepts dozens of business-specific props, it is no longer a layout primitive. Move the business UI into its feature components and give the shell slots or focused layout props.

For a concrete composition reference, see DOM Studio’s responsive application layout block. It shows a persistent navigation area and an independently scrolling work area built from smaller components.

Visual workflow from interface primitive to feature component to complete Vue application shell

6. Verify reuse with a component acceptance test

Do not call a component reusable because it appears twice. Prove it by rendering it in at least two contexts with different content, states, or layouts.

Use this minimum acceptance test for every component in ui/ or patterns/:

  1. Render it with default props.
  2. Render it with every meaningful variant or slot.
  3. Verify keyboard interaction and disabled or loading behavior where applicable.
  4. Verify emitted events and accessible names.
  5. Confirm the component has no import from a feature, page, or route module.

For an AppDialog, that means testing a confirmation flow and a destructive-action flow. For a PanelCard, it means testing it with and without a header or actions slot. For a CustomerHealthTable, test domain behavior within its feature directory, not as a supposedly generic table.

Expected result: dependency direction stays clear: pages depend on features, features depend on patterns and primitives, and primitives do not depend on pages.

Troubleshooting: if the second use case needs a special prop that only makes sense for one feature, do not add it to the shared component. Compose a feature wrapper around the shared component instead.

7. Document the system where developers make choices

The final step is operational: document each shared component’s purpose, API, examples, and boundaries. A small component index or playground prevents duplicate work because developers can discover the existing primitive before building another version.

Document these details for each reusable component:

  • what problem it solves
  • public props, events, and slots
  • accessibility and keyboard expectations
  • two realistic examples
  • known non-goals
  • source location and owner

For forms, make validation, field metadata, and error behavior part of the same documented contract. DOM Studio’s form components illustrate the value of keeping form controls and schema-ready behavior close to the application UI system.

Watch: component reusability patterns

This talk explores a progression from basic reuse to more adaptable component design. Use it as a companion to the implementation steps above, then apply the ideas to a single component in your own codebase.

Build the next screen from the system, not from scratch

You now have a practical structure for Vue application components: primitives for stable UI behavior, patterns for recurring compositions, feature components for product workflows, composables for shared logic, and pages for route assembly.

Our next action is to pick one repeated screen, map its current components to the four layers, and extract only the smallest proven primitive first. If you are evaluating an editable system instead of building every layer manually, explore DOM Studio’s library of Vue wrappers, headless elements, form tools, and application blocks. The same architecture also applies when you use tools such as Vuetify, PrimeVue, or shadcn-vue: keep generic UI contracts separate from domain workflows, then compose upward.

Frequently asked questions

How small should Vue application components be?

Make a component as small as its stable responsibility allows. A button can be a primitive. A filter bar can be a shared pattern. A customer health table is a feature component. Splitting a tightly coordinated group into several files without a reuse or testing benefit adds indirection rather than flexibility.

When should I use a slot instead of a prop?

Use a prop for a known value or behavior that the component owns. Use a slot when the caller needs to provide arbitrary markup or another component. For example, loading is a prop, while a custom dialog footer is usually a named slot.

Should generic Vue components access a store?

Usually no. Generic primitives should receive the state they need through props and emit user intent. Feature components can connect to a store, service, or composable, then pass focused data and handlers down to UI components.

Is DOM Studio a replacement for Vue UI libraries?

DOM Studio provides editable Vue and Web Component UI layers, including headless elements, Vue wrappers, application blocks, form tooling, metadata, and documentation. Whether it replaces or complements another UI library depends on your existing component system, styling needs, and migration plan.