Table of Contents
- Understanding Reusable Components: Core Principles
- Leveraging Vue’s Core Features for Reusability
- The Composition API: Sharing Logic with Composables
- Styling Reusable Components: Isolation and Theming
- Testing and Documenting Reusable Components
- Best Practices for Reusable Component Design
- Common Pitfalls to Avoid
- Conclusion
- References
1. Understanding Reusable Components: Core Principles
Before diving into implementation, it’s critical to align on the principles that make a component truly reusable. A well-designed reusable component should adhere to these guidelines:
Single Responsibility
A component should do one thing and do it well. For example, a Button component handles button behavior (clicking, states like disabled/loading), while a Card component manages layout (header, body, footer). Avoid “god components” that handle multiple unrelated tasks (e.g., a UserProfile that also fetches data and renders charts).
Encapsulation
Internal logic, state, and styling should be hidden from the outside world. Only expose necessary inputs (props) and outputs (events) to avoid tight coupling with parent components.
Configurable
Components should adapt to different contexts via props, slots, or CSS variables. For example, a Button might support variant (primary, secondary), size (sm, md, lg), and disabled props.
Testable
Isolate components from external dependencies (e.g., APIs) to make them easy to test. Use composables (see Section 3) to extract side effects.
Accessible
Ensure components work with screen readers, keyboard navigation, and follow WCAG guidelines (e.g., proper aria attributes, focus management).
2. Leveraging Vue’s Core Features for Reusability
Vue provides built-in tools to enforce the principles above. Let’s break down how to use them effectively.
Props: Defining Inputs
Props are the primary way to pass data from parent to child components. They enable configurability and ensure components remain flexible.
Best Practices for Props:
- Validate props with
type,required,default, andvalidatorto catch errors early. - Use TypeScript for stricter type safety (Vue 3+).
- Keep props simple: Avoid complex objects; prefer primitive values or small, focused objects.
Example: A Configurable Button Component
<!-- Button.vue -->
<template>
<button
:class="['btn', `btn-${variant}`, `btn-${size}`, { 'btn-disabled': disabled }]"
:disabled="disabled || loading"
@click="$emit('click')"
>
<slot />
<span v-if="loading" class="spinner" />
</button>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
// Prop validation with TypeScript
const props = defineProps<{
variant?: 'primary' | 'secondary' | 'danger'; // Union type for allowed variants
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
}>();
// Default values (for non-required props)
withDefaults(props, {
variant: 'primary',
size: 'md',
disabled: false,
loading: false,
});
</script>
<style scoped>
.btn {
padding: 0.5rem 1rem;
border-radius: 4px;
cursor: pointer;
}
.btn-primary { background: var(--primary); color: white; }
.btn-secondary { background: var(--secondary); color: black; }
.btn-danger { background: var(--danger); color: white; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
.btn-md { padding: 0.5rem 1rem; font-size: 1rem; }
.btn-lg { padding: 0.75rem 1.5rem; font-size: 1.25rem; }
.btn-disabled { opacity: 0.7; cursor: not-allowed; }
</style>
Usage in Parent:
<!-- Parent.vue -->
<Button
variant="secondary"
size="lg"
:loading="isSubmitting"
@click="handleSubmit"
>
Submit
</Button>
Emits: Handling Outputs
Emits (custom events) let child components communicate with parents. They are the “output” counterpart to props, ensuring components remain decoupled.
Best Practices for Emits:
- Document events (e.g., with JSDoc or TypeScript) so consumers know what to expect.
- Use
v-modelcompatibility for two-way binding (viaupdate:modelValue). - Pass relevant data in events (e.g., user input, selected items).
Example: Form Input with v-model
Vue’s v-model is syntactic sugar for :modelValue prop + update:modelValue event. Use this pattern for form components:
<!-- FormInput.vue -->
<template>
<input
:value="modelValue"
@input="$emit('update:modelValue', $event.target.value)"
:placeholder="placeholder"
:disabled="disabled"
/>
</template>
<script setup lang="ts">
import { defineProps, defineEmits } from 'vue';
// Props
const props = defineProps<{
modelValue: string; // Bound via v-model
placeholder?: string;
disabled?: boolean;
}>();
// Emits (TypeScript interface for type safety)
const emit = defineEmits<{
(e: 'update:modelValue', value: string): void;
(e: 'focus'): void; // Additional event
}>();
// Emit 'focus' on input focus
// (Add @focus="$emit('focus')" to the input in template)
</script>
Usage with v-model:
<FormInput
v-model="username"
placeholder="Enter username"
@focus="handleFocus"
/>
Slots: Flexible Content Distribution
Slots let parents inject custom content into child components, enabling dynamic UIs without hardcoding markup.
Types of Slots:
- Default slot: For single, primary content (e.g., a button label).
- Named slots: For multiple content areas (e.g., card header/body/footer).
- Scoped slots: Pass data from child to parent (e.g., item data in a list).
Example: Card Component with Named Slots
<!-- Card.vue -->
<template>
<div class="card">
<!-- Named slot for header -->
<div class="card-header" v-if="$slots.header">
<slot name="header" />
</div>
<!-- Default slot for body -->
<div class="card-body">
<slot />
</div>
<!-- Named slot for footer -->
<div class="card-footer" v-if="$slots.footer">
<slot name="footer" />
</div>
</div>
</template>
<style scoped>
.card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 1rem;
}
.card-header { font-weight: bold; margin-bottom: 0.5rem; }
.card-footer { margin-top: 1rem; border-top: 1px solid #e5e7eb; padding-top: 0.5rem; }
</style>
Usage with Named Slots:
<Card>
<template #header>
<h2>Welcome</h2>
</template>
<!-- Default slot content -->
<p>This is a flexible card component.</p>
<template #footer>
<Button variant="primary">Learn More</Button>
</template>
</Card>
Scoped Slots: Dynamic Content with Child Data
Scoped slots let parents customize content using data from the child (e.g., rendering a list item with child-provided data).
Example: List Component with Scoped Slot
<!-- UserList.vue -->
<template>
<ul class="user-list">
<li v-for="user in users" :key="user.id">
<!-- Pass user data to parent via scoped slot -->
<slot name="user" :user="user" />
</li>
</ul>
</template>
<script setup lang="ts">
import { defineProps } from 'vue';
const props = defineProps<{
users: Array<{ id: number; name: string; email: string }>;
}>();
</script>
Usage: Customizing List Items
<UserList :users="users">
<template #user="{ user }"> <!-- Destructure user from slot props -->
<div class="user-item">
<h3>{{ user.name }}</h3>
<p>{{ user.email }}</p>
</div>
</template>
</UserList>
3. The Composition API: Sharing Logic with Composables
Vue 3’s Composition API revolutionizes reusability by letting you extract and share logic across components via composables (functions prefixed with use). This replaces the Options API’s mixins (which suffer from naming conflicts and opacity).
Benefits of Composables:
- Reusable logic: Extract features like state management, API calls, or validation.
- Isolation: Logic is self-contained and testable.
- Flexibility: Mix and match composables in components.
Example: useCounter Composable
// composables/useCounter.ts
import { ref, computed } from 'vue';
export function useCounter(initialValue = 0) {
const count = ref(initialValue);
const increment = () => count.value++;
const decrement = () => count.value--;
const double = computed(() => count.value * 2);
return { count, increment, decrement, double };
}
Usage in Components:
<!-- CounterA.vue -->
<script setup>
import { useCounter } from '@/composables/useCounter';
const { count, increment } = useCounter(5); // Start at 5
</script>
<template>
<div>
Count: {{ count }}
<button @click="increment">+</button>
</div>
</template>
<!-- CounterB.vue -->
<script setup>
import { useCounter } from '@/composables/useCounter';
const { count, decrement, double } = useCounter(); // Start at 0
</script>
<template>
<div>
Count: {{ count }} (Double: {{ double }})
<button @click="decrement">-</button>
</div>
</template>
Advanced Example: useLocalStorage for Persistence
Composables can handle side effects (e.g., localStorage) while keeping components clean:
// composables/useLocalStorage.ts
import { ref, watch } from 'vue';
export function useLocalStorage<T>(key: string, initialValue: T) {
// Load from localStorage or use initial value
const storedValue = localStorage.getItem(key);
const value = ref<T>(storedValue ? JSON.parse(storedValue) : initialValue);
// Save to localStorage on change
watch(value, (newValue) => {
localStorage.setItem(key, JSON.stringify(newValue));
}, { deep: true }); // Deep watch for objects/arrays
return value;
}
Usage:
<script setup>
import { useLocalStorage } from '@/composables/useLocalStorage';
const theme = useLocalStorage('theme', 'light'); // Persists across page reloads
</script>
4. Styling Reusable Components: Isolation and Theming
Styling is critical for reusable components—they must avoid conflicting with global styles and support theming.
Key Strategies:
Scoped Styles
Vue’s <style scoped> adds unique attributes to component elements, ensuring styles don’t leak globally:
<style scoped>
/* Only affects elements in this component */
.btn {
padding: 0.5rem 1rem;
}
</style>
Note: Use :deep() (Vue 3) to target child components or third-party libraries:
:deep(.external-library-class) { /* Styles pierce scoping */ }
CSS Modules
For stricter isolation, use CSS Modules to generate unique class names:
<style module>
/* Classes are scoped to this component via $style object */
.btn { padding: 0.5rem; }
.primary { background: var(--primary); }
</style>
<template>
<button :class="[ $style.btn, $style.primary ]">Click me</button>
</template>
Theming with CSS Variables
Let consumers customize styles via CSS variables (defined in :root or parent components):
<!-- Button.vue -->
<style scoped>
.btn {
background: var(--btn-bg, #007bff); /* Fallback if not provided */
color: var(--btn-color, white);
}
</style>
Usage in Parent:
<template>
<div class="custom-theme">
<Button>Custom Button</Button>
</div>
</template>
<style>
.custom-theme {
--btn-bg: #28a745; /* Override variable for this subtree */
--btn-color: black;
}
</style>
Utility-First Frameworks
Libraries like Tailwind CSS work well for reusable components, as they enforce consistency and reduce CSS bloat. Use @apply to extract common patterns:
<style scoped>
.btn {
@apply px-4 py-2 rounded font-medium;
}
.btn-primary {
@apply bg-blue-500 text-white hover:bg-blue-600;
}
</style>
5. Testing and Documenting Reusable Components
Reusable components are only useful if they’re reliable and easy to understand. Invest in testing and documentation.
Testing with Vue Test Utils
Test components in isolation using Vue Test Utils. Focus on:
- Prop validation (e.g., does
variant="primary"apply the correct class?). - Event emission (e.g., does clicking emit
click?). - Slot rendering (e.g., does the header slot appear?).
Example Test for Button Component:
// Button.spec.ts
import { mount } from '@vue/test-utils';
import Button from './Button.vue';
describe('Button', () => {
it('applies "btn-primary" class when variant is primary', () => {
const wrapper = mount(Button, { props: { variant: 'primary' } });
expect(wrapper.classes()).toContain('btn-primary');
});
it('emits "click" when clicked', async () => {
const wrapper = mount(Button);
await wrapper.trigger('click');
expect(wrapper.emitted('click')).toBeTruthy();
});
});
Documentation with Storybook
Storybook lets you build an interactive component library with live examples, making it easy for teams to explore and use components.
Example Story for Button:
// Button.stories.ts
import Button from './Button.vue';
export default {
title: 'Components/Button',
component: Button,
argTypes: {
variant: { control: 'select', options: ['primary', 'secondary', 'danger'] },
size: { control: 'radio', options: ['sm', 'md', 'lg'] },
disabled: { control: 'boolean' },
},
};
export const Primary = (args) => ({
components: { Button },
setup() { return { args }; },
template: '<Button v-bind="args">Primary Button</Button>',
});
Run storybook dev to launch a browser-based docs site with interactive controls for props.
6. Best Practices for Reusable Component Design
To summarize, follow these guidelines:
- Start small: Build minimal components and add features only when needed.
- Use TypeScript: Enforce prop/emit types for robustness.
- Document everything: Add JSDoc comments, Storybook stories, and usage examples.
- Prioritize accessibility: Add
ariaattributes, keyboard support, and focus management. - Version components: If sharing across projects, use semantic versioning (e.g.,
@my-org/[email protected]). - Avoid external dependencies: Keep components lightweight (e.g., don’t bundle lodash; use native methods).
7. Common Pitfalls to Avoid
- Over-engineering: Adding props/slots “just in case” bloats components. YAGNI (You Aren’t Gonna Need It).
- Tight coupling: Components that depend on parent state or global stores are hard to reuse.
- Ignoring edge cases: Handle loading, error, and empty states (e.g., a list with no items).
- Poor naming: Use clear, consistent names (e.g.,
FormInputinstead ofMyInput). - Forgetting mobile: Ensure components are responsive (use relative units like
rem, avoid fixed widths).
8. Conclusion
Designing reusable components in Vue.js is a skill that balances art and engineering. By following the principles of single responsibility, encapsulation, and configurability, and leveraging Vue’s features like props, slots, and composables, you can build components that scale across projects and teams.
Remember: the best reusable components are simple, documented, and adaptable. Invest time in testing and collaboration with your team to refine them over time.