vue-best-practices
Vue.js Best Practices
Comprehensive best practices guide for Vue.js 3 applications. Contains guidelines across multiple categories to ensure idiomatic, maintainable, and scalable Vue.js code, including Tailwind CSS integration patterns for utility-first styling and PrimeVue component library best practices.
When to Apply
Reference these guidelines when:
- Writing new Vue components or composables
- Implementing features with Composition API
- Reviewing code for Vue.js patterns compliance
- Refactoring existing Vue.js code
- Setting up component architecture
- Working with Nuxt.js applications
- Styling Vue components with Tailwind CSS utility classes
- Creating design systems with Tailwind and Vue
- Using PrimeVue component library natively
- Customizing PrimeVue theme through design tokens and definePreset()
Rule Categories
| Category | Focus | Prefix |
|---|---|---|
| Composition API | Proper use of Composition API patterns | composition- |
| Component Design | Component structure and organization | component- |
| Reactivity | Reactive state management patterns | reactive- |
| Props & Events | Component communication patterns | props- |
| Template Patterns | Template syntax best practices | template- |
| Code Organization | Project and code structure | organization- |
| TypeScript | Type-safe Vue.js patterns | typescript- |
| Error Handling | Error boundaries and handling | error- |
| Tailwind CSS | Utility-first styling patterns | tailwind- |
| PrimeVue | Component library integration patterns | primevue- |
Quick Reference
1. Composition API Best Practices
composition-script-setup- Always use<script setup>for single-file componentscomposition-ref-vs-reactive- Useref()for primitives,reactive()for objectscomposition-computed-derived- Usecomputed()for all derived statecomposition-watch-side-effects- Usewatch()/watchEffect()only for side effectscomposition-composables- Extract reusable logic into composablescomposition-lifecycle-order- Place lifecycle hooks after reactive state declarationscomposition-avoid-this- Never usethisin Composition API
2. Component Design
component-single-responsibility- One component, one purposecomponent-naming-convention- Use PascalCase for components, kebab-case in templatescomponent-small-focused- Keep components under 200 linescomponent-presentational-container- Separate logic from presentation when beneficialcomponent-slots-flexibility- Use slots for flexible component compositioncomponent-expose-minimal- Only expose what's necessary viadefineExpose()
3. Reactivity Patterns
reactive-const-refs- Always declare refs withconstreactive-unwrap-template- Let Vue unwrap refs in templates (no.value)reactive-shallow-large-data- UseshallowRef()/shallowReactive()for large non-reactive datareactive-readonly-props- Usereadonly()to prevent mutationsreactive-toRefs-destructure- UsetoRefs()when destructuring reactive objectsreactive-avoid-mutation- Prefer immutable updates for complex state
4. Props & Events
props-define-types- Always define prop types withdefineProps<T>()props-required-explicit- Be explicit about required vs optional propsprops-default-values- Provide sensible defaults withwithDefaults()props-immutable- Never mutate props directlyprops-validation- Use validator functions for complex prop validationevents-define-emits- Always define emits withdefineEmits<T>()events-naming- Use kebab-case for event names in templatesevents-payload-objects- Pass objects for events with multiple values
5. Template Patterns
template-v-if-v-show- Usev-iffor conditional rendering,v-showfor togglingtemplate-v-for-key- Always use unique, stable:keywithv-fortemplate-v-if-v-for- Never usev-ifandv-foron the same elementtemplate-computed-expressions- Move complex expressions to computed propertiestemplate-event-modifiers- Use event modifiers (.prevent,.stop) appropriatelytemplate-v-bind-shorthand- Use shorthand syntax (:forv-bind,@forv-on)template-v-model-modifiers- Use v-model modifiers (.trim,.number,.lazy)
6. Code Organization
organization-feature-folders- Organize by feature, not by typeorganization-composables-folder- Keep composables in dedicatedcomposables/folderorganization-barrel-exports- Use index files for clean importsorganization-consistent-naming- Follow consistent naming conventionsorganization-colocation- Colocate related files (component, tests, styles)
7. TypeScript Integration
typescript-generic-components- Use generics for reusable typed componentstypescript-prop-types- Use TypeScript interfaces for prop definitionstypescript-emit-types- Type emit payloads explicitlytypescript-ref-typing- Specify types for refs when not inferredtypescript-template-refs- Type template refs withref<InstanceType<typeof Component> | null>(null)
8. Error Handling
error-boundaries- UseonErrorCaptured()for component error boundarieserror-async-handling- Handle errors in async operations explicitlyerror-provide-fallbacks- Provide fallback UI for error stateserror-logging- Log errors appropriately for debugging
9. Tailwind CSS
tailwind-utility-first- Apply utility classes directly in templates, avoid custom CSStailwind-class-order- Use consistent class ordering (layout → spacing → typography → visual)tailwind-responsive-mobile-first- Use mobile-first responsive design (sm:,md:,lg:)tailwind-component-extraction- Extract repeated utility patterns into Vue componentstailwind-dynamic-classes- Use computed properties or helper functions for dynamic classestailwind-complete-class-strings- Always use complete class strings, never concatenatetailwind-state-variants- Use state variants (hover:,focus:,active:) for interactionstailwind-dark-mode- Usedark:prefix for dark mode supporttailwind-design-tokens- Configure design tokens in Tailwind config for consistencytailwind-avoid-apply-overuse- Limit@applyusage; prefer Vue components for abstraction
10. PrimeVue
primevue-use-natively- Use PrimeVue components as-is with their documented props and APIprimevue-design-tokens- Customize appearance exclusively through design tokens anddefinePreset()primevue-no-pt-overrides- NEVER use PassThrough (pt) API to restyle componentsprimevue-no-unstyled-mode- NEVER use unstyled mode to strip and rebuild component stylesprimevue-no-wrapper-components- NEVER wrap PrimeVue components just to override their stylingprimevue-props-api- Use built-in props (severity, size, outlined, rounded, raised, text) for variantsprimevue-css-layers- Configure CSS layer ordering for clean Tailwind coexistenceprimevue-typed-components- Leverage PrimeVue's TypeScript support for type safetyprimevue-accessibility- Maintain WCAG compliance with proper aria attributesprimevue-lazy-loading- Use async components for large PrimeVue imports
Key Principles
Composition API Best Practices
The Composition API is the recommended approach for Vue.js 3. Follow these patterns:
- Always use
<script setup>: More concise, better TypeScript inference, and improved performance - Organize code by logical concern: Group related state, computed properties, and functions together
- Extract reusable logic to composables: Follow the
useprefix convention (e.g.,useAuth,useFetch) - Keep setup code readable: Order: props/emits, reactive state, computed, watchers, methods, lifecycle hooks
Component Design Principles
Well-designed components are the foundation of maintainable Vue applications:
- Single Responsibility: Each component should do one thing well
- Props Down, Events Up: Follow unidirectional data flow
- Prefer Composition over Inheritance: Use composables and slots for code reuse
- Keep Components Small: If a component exceeds 200 lines, consider splitting it
Reactivity Guidelines
Understanding Vue's reactivity system is crucial:
- ref vs reactive: Use
ref()for primitives and values you'll reassign; usereactive()for objects you'll mutate - Computed for derived state: Never store derived state in refs; use
computed()instead - Watch for side effects: Only use
watch()for side effects like API calls or localStorage - Be mindful of reactivity loss: Don't destructure reactive objects without
toRefs()
Props & Events Patterns
Proper component communication ensures maintainable code:
- Type your props: Use TypeScript interfaces with
defineProps<T>() - Validate complex props: Use validator functions for business logic validation
- Emit typed events: Use
defineEmits<T>()for type-safe event handling - Use v-model for two-way binding: Implement
modelValueprop andupdate:modelValueemit
Common Patterns
Script Setup Structure
Recommended structure for <script setup>:
<script setup lang="ts">
// 1. Imports
import { ref, computed, watch, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import type { User } from '@/types'
// 2. Props and Emits
const props = defineProps<{
userId: string
initialData?: User
}>()
const emit = defineEmits<{
submit: [user: User]
cancel: []
}>()
// 3. Composables
const router = useRouter()
const { user, loading, error } = useUser(props.userId)
// 4. Reactive State
const formData = ref({ name: '', email: '' })
const isEditing = ref(false)
// 5. Computed Properties
const isValid = computed(() => {
return formData.value.name.length > 0 && formData.value.email.includes('@')
})
// 6. Watchers (for side effects only)
watch(() => props.userId, (newId) => {
fetchUserData(newId)
})
// 7. Methods
function handleSubmit() {
if (isValid.value) {
emit('submit', formData.value)
}
}
// 8. Lifecycle Hooks
onMounted(() => {
if (props.initialData) {
formData.value = { ...props.initialData }
}
})
</script>
Composable Pattern
Correct: Well-structured composable
// composables/useUser.ts
import { ref, computed, watch } from 'vue'
import type { Ref } from 'vue'
import type { User } from '@/types'
export function useUser(userId: Ref<string> | string) {
// State
const user = ref<User | null>(null)
const loading = ref(false)
const error = ref<Error | null>(null)
// Computed
const fullName = computed(() => {
if (!user.value) return ''
return `${user.value.firstName} ${user.value.lastName}`
})
// Methods
async function fetchUser(id: string) {
loading.value = true
error.value = null
try {
const response = await api.getUser(id)
user.value = response.data
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
// Auto-fetch when userId changes (if reactive)
if (isRef(userId)) {
watch(userId, (newId) => fetchUser(newId), { immediate: true })
} else {
fetchUser(userId)
}
// Return
return {
user: readonly(user),
fullName,
loading: readonly(loading),
error: readonly(error),
refresh: () => fetchUser(unref(userId))
}
}
Props with Defaults
Correct: Typed props with defaults
<script setup lang="ts">
interface Props {
title: string
size?: 'sm' | 'md' | 'lg'
disabled?: boolean
items?: string[]
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
disabled: false,
items: () => [] // Use factory function for arrays/objects
})
</script>
Event Handling
Correct: Typed emits with payloads
<script setup lang="ts">
interface FormData {
name: string
email: string
}
const emit = defineEmits<{
submit: [data: FormData]
cancel: []
'update:modelValue': [value: string]
}>()
function handleSubmit(data: FormData) {
emit('submit', data)
}
</script>
v-model Implementation
Correct: Custom v-model with defineModel (Vue 3.4+)
<script setup lang="ts">
const model = defineModel<string>({ required: true })
// Or with default
const modelWithDefault = defineModel<string>({ default: '' })
</script>
<template>
<input :value="model" @input="model = $event.target.value" />
</template>
Correct: Custom v-model (Vue 3.3 and earlier)
<script setup lang="ts">
const props = defineProps<{
modelValue: string
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const value = computed({
get: () => props.modelValue,
set: (val) => emit('update:modelValue', val)
})
</script>
<template>
<input v-model="value" />
</template>
Template Ref Typing
Correct: Typed template refs
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import MyComponent from './MyComponent.vue'
// DOM element ref
const inputRef = ref<HTMLInputElement | null>(null)
// Component ref
const componentRef = ref<InstanceType<typeof MyComponent> | null>(null)
onMounted(() => {
inputRef.value?.focus()
componentRef.value?.someExposedMethod()
})
</script>
<template>
<input ref="inputRef" />
<MyComponent ref="componentRef" />
</template>
Provide/Inject with Types
Correct: Type-safe provide/inject
// types/injection-keys.ts
import type { InjectionKey, Ref } from 'vue'
import type { User } from './user'
export const UserKey: InjectionKey<Ref<User>> = Symbol('user')
// Parent component
import { provide, ref } from 'vue'
import { UserKey } from '@/types/injection-keys'
const user = ref<User>({ id: '1', name: 'John' })
provide(UserKey, user)
// Child component
import { inject } from 'vue'
import { UserKey } from '@/types/injection-keys'
const user = inject(UserKey)
if (!user) {
throw new Error('User not provided')
}
Error Boundary Component
Correct: Error boundary with onErrorCaptured
<script setup lang="ts">
import { ref, onErrorCaptured } from 'vue'
const error = ref<Error | null>(null)
onErrorCaptured((err) => {
error.value = err
// Return false to stop error propagation
return false
})
function reset() {
error.value = null
}
</script>
<template>
<div v-if="error" class="error-boundary">
<p>Something went wrong: {{ error.message }}</p>
<button @click="reset">Try again</button>
</div>
<slot v-else />
</template>
Async Component Loading
Correct: Async components with loading/error states
import { defineAsyncComponent } from 'vue'
const AsyncDashboard = defineAsyncComponent({
loader: () => import('./Dashboard.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // Show loading after 200ms
timeout: 10000 // Timeout after 10s
})
Tailwind CSS Best Practices
Vue's component-based architecture pairs naturally with Tailwind's utility-first approach. Follow these patterns for maintainable, consistent styling.
Utility-First Approach
Apply Tailwind utility classes directly in Vue templates for rapid, consistent styling:
Correct: Utility classes in template
<template>
<div class="mx-auto max-w-md rounded-xl bg-white p-6 shadow-lg">
<h2 class="text-xl font-semibold text-gray-900">{{ title }}</h2>
<p class="mt-2 text-gray-600">{{ description }}</p>
<button class="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
{{ buttonText }}
</button>
</div>
</template>
Class Ordering Convention
Maintain consistent class ordering for readability. Recommended order:
- Layout -
flex,grid,block,hidden - Positioning -
relative,absolute,fixed - Box Model -
w-,h-,m-,p- - Typography -
text-,font-,leading- - Visual -
bg-,border-,rounded-,shadow- - Interactive -
hover:,focus:,active:
Use the official Prettier plugin (prettier-plugin-tailwindcss) to automatically sort classes.
Responsive Design (Mobile-First)
Use Tailwind's responsive prefixes for mobile-first responsive design:
Correct: Mobile-first responsive layout
<template>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<article
v-for="item in items"
:key="item.id"
class="p-4 text-sm sm:p-6 sm:text-base lg:text-lg"
>
<h3 class="font-medium">{{ item.title }}</h3>
</article>
</div>
</template>
Breakpoint Reference:
sm:- 640px and upmd:- 768px and uplg:- 1024px and upxl:- 1280px and up2xl:- 1536px and up
State Variants
Use state variants for interactive elements:
Correct: State variants for buttons
<template>
<button
class="rounded-lg bg-indigo-600 px-4 py-2 font-medium text-white
transition-colors duration-150
hover:bg-indigo-700
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2
active:bg-indigo-800
disabled:cursor-not-allowed disabled:opacity-50"
:disabled="isLoading"
>
{{ isLoading ? 'Loading...' : 'Submit' }}
</button>
</template>
Dark Mode Support
Use the dark: prefix for dark mode styles:
Correct: Dark mode support
<template>
<div class="bg-white dark:bg-gray-900">
<h1 class="text-gray-900 dark:text-white">{{ title }}</h1>
<p class="text-gray-600 dark:text-gray-400">{{ content }}</p>
<div class="border-gray-200 dark:border-gray-700 rounded-lg border p-4">
<slot />
</div>
</div>
</template>
Dynamic Classes with Computed Properties
Use computed properties for conditional class binding:
Correct: Computed classes for variants
<script setup lang="ts">
import { computed } from 'vue'
type ButtonVariant = 'primary' | 'secondary' | 'danger'
type ButtonSize = 'sm' | 'md' | 'lg'
const props = withDefaults(defineProps<{
variant?: ButtonVariant
size?: ButtonSize
}>(), {
variant: 'primary',
size: 'md'
})
const variantClasses = computed(() => {
const variants: Record<ButtonVariant, string> = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500'
}
return variants[props.variant]
})
const sizeClasses = computed(() => {
const sizes: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
return sizes[props.size]
})
const buttonClasses = computed(() => [
'inline-flex items-center justify-center rounded-md font-medium',
'transition-colors duration-150',
'focus:outline-none focus:ring-2 focus:ring-offset-2',
variantClasses.value,
sizeClasses.value
])
</script>
<template>
<button :class="buttonClasses">
<slot />
</button>
</template>
Class Variance Authority (CVA) Pattern
For complex component variants, use the CVA pattern with a helper library:
Correct: CVA-style variant management
<script setup lang="ts">
import { computed } from 'vue'
import { cva, type VariantProps } from 'class-variance-authority'
const button = cva(
'inline-flex items-center justify-center rounded-md font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2',
{
variants: {
intent: {
primary: 'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
secondary: 'bg-gray-200 text-gray-900 hover:bg-gray-300 focus:ring-gray-500',
danger: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500'
},
size: {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
}
},
defaultVariants: {
intent: 'primary',
size: 'md'
}
}
)
type ButtonProps = VariantProps<typeof button>
const props = defineProps<{
intent?: ButtonProps['intent']
size?: ButtonProps['size']
}>()
const classes = computed(() => button({ intent: props.intent, size: props.size }))
</script>
<template>
<button :class="classes">
<slot />
</button>
</template>
Component Extraction for Reusable Patterns
Extract repeated utility patterns into Vue components:
Correct: Reusable card component
<!-- components/BaseCard.vue -->
<script setup lang="ts">
withDefaults(defineProps<{
padding?: 'none' | 'sm' | 'md' | 'lg'
shadow?: 'none' | 'sm' | 'md' | 'lg'
}>(), {
padding: 'md',
shadow: 'md'
})
</script>
<template>
<div
class="rounded-xl bg-white dark:bg-gray-800"
:class="[
{
'p-0': padding === 'none',
'p-4': padding === 'sm',
'p-6': padding === 'md',
'p-8': padding === 'lg'
},
{
'shadow-none': shadow === 'none',
'shadow-sm': shadow === 'sm',
'shadow-md': shadow === 'md',
'shadow-lg': shadow === 'lg'
}
]"
>
<slot />
</div>
</template>
Tailwind Configuration with Design Tokens
Define design tokens in your Tailwind config for consistency:
Correct: tailwind.config.js with design tokens
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {
colors: {
// Semantic color tokens
primary: {
50: '#eff6ff',
100: '#dbeafe',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8'
},
surface: {
light: '#ffffff',
dark: '#1f2937'
}
},
spacing: {
// Custom spacing tokens
'4.5': '1.125rem',
'18': '4.5rem'
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif']
},
borderRadius: {
'4xl': '2rem'
}
}
},
plugins: []
}
Tailwind CSS v4 Configuration
For Tailwind CSS v4, use the CSS-first configuration approach:
Correct: Tailwind v4 CSS configuration
/* main.css */
@import "tailwindcss";
@theme {
/* Custom colors */
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
/* Custom spacing */
--spacing-4-5: 1.125rem;
--spacing-18: 4.5rem;
/* Custom fonts */
--font-family-sans: 'Inter', system-ui, sans-serif;
}
Using cn() Helper for Conditional Classes
Use a class merging utility for conditional classes:
Correct: cn() helper with clsx and tailwind-merge
// utils/cn.ts
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Usage in component:
<script setup lang="ts">
import { cn } from '@/utils/cn'
const props = defineProps<{
class?: string
isActive?: boolean
}>()
</script>
<template>
<div
:class="cn(
'rounded-lg border p-4 transition-colors',
isActive ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white',
props.class
)"
>
<slot />
</div>
</template>
PrimeVue Best Practices
PrimeVue is a comprehensive Vue UI component library with 90+ components. Use it natively. Do not fight the framework.
Core Principle
PrimeVue v4 has a complete theming system based on design tokens and presets. This is the ONLY supported customization path. Do not bypass it.
Installation & Setup
Correct: PrimeVue v4 setup with theme preset
// main.ts
import { createApp } from 'vue'
import PrimeVue from 'primevue/config'
import Aura from '@primevue/themes/aura'
import App from './App.vue'
const app = createApp(App)
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
prefix: 'p',
darkModeSelector: '.dark-mode',
// Ensure clean coexistence with Tailwind
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities'
}
}
}
})
app.mount('#app')
Correct: Component registration (tree-shakeable)
// main.ts - Register only components you use
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
app.component('Button', Button)
app.component('DataTable', DataTable)
app.component('Column', Column)
Theme Customization with Design Tokens
Customize PrimeVue appearance through the design token system, NOT by overriding component internals:
Correct: Custom preset using definePreset()
// theme/my-preset.ts
import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura'
const MyPreset = definePreset(Aura, {
semantic: {
primary: {
50: '{indigo.50}',
100: '{indigo.100}',
200: '{indigo.200}',
300: '{indigo.300}',
400: '{indigo.400}',
500: '{indigo.500}',
600: '{indigo.600}',
700: '{indigo.700}',
800: '{indigo.800}',
900: '{indigo.900}',
950: '{indigo.950}'
},
colorScheme: {
light: {
surface: {
0: '#ffffff',
50: '{slate.50}',
100: '{slate.100}',
// ...
}
},
dark: {
surface: {
0: '#0a0a0a',
50: '{slate.950}',
// ...
}
}
}
}
})
export default MyPreset
Usage in main.ts:
import MyPreset from './theme/my-preset'
app.use(PrimeVue, {
theme: {
preset: MyPreset
}
})
Using Components Natively
Use PrimeVue components with their built-in props — do not restyle them:
Correct: Use built-in severity and variant props
<template>
<!-- Use severity prop for color variants -->
<Button label="Save" severity="success" />
<Button label="Delete" severity="danger" />
<Button label="Info" severity="info" outlined />
<!-- Use size prop -->
<Button label="Small" size="small" />
<Button label="Large" size="large" />
<!-- Use style variant props -->
<Button label="Text" text />
<Button label="Outlined" outlined />
<Button label="Rounded" rounded />
<Button label="Raised" raised />
<!-- Use icon prop -->
<Button label="Search" icon="pi pi-search" />
<Button icon="pi pi-check" rounded aria-label="Confirm" />
</template>
Correct: Use slot API for content customization
<template>
<DataTable :value="users" stripedRows paginator :rows="10">
<Column field="name" header="Name" sortable />
<Column field="status" header="Status">
<template #body="{ data }">
<Tag :value="data.status" :severity="getStatusSeverity(data.status)" />
</template>
</Column>
</DataTable>
</template>
What NEVER to Do with PrimeVue
NEVER use PassThrough (pt) to restyle components:
<!-- INCORRECT: Fighting the framework -->
<Button
label="Submit"
:pt="{
root: { class: 'bg-blue-600 rounded-lg px-4 py-2' },
label: { class: 'font-medium' }
}"
/>
<!-- CORRECT: Use the component natively -->
<Button label="Submit" />
NEVER use unstyled mode to rebuild components:
// INCORRECT: Stripping the framework and rebuilding it
app.use(PrimeVue, { unstyled: true })
// CORRECT: Use a theme preset
app.use(PrimeVue, { theme: { preset: Aura } })
NEVER create wrapper components to override styling:
<!-- INCORRECT: Unnecessary abstraction -->
<!-- components/AppButton.vue -->
<Button v-bind="$attrs" :pt="{ root: { class: customClasses } }">
<slot />
</Button>
<!-- CORRECT: Use Button directly everywhere -->
<Button label="Submit" severity="primary" />
NEVER target PrimeVue internal CSS classes:
/* INCORRECT: Fragile, breaks on updates */
.p-button-label { font-weight: 700; }
.p-datatable-header { background: #f0f0f0; }
/* CORRECT: Customize through design tokens in definePreset() */
Tailwind and PrimeVue Coexistence
Tailwind and PrimeVue serve different roles — do not mix their responsibilities:
- PrimeVue owns: component appearance, internal spacing, interactive states, component variants
- Tailwind owns: page layout, spacing between components, custom non-library elements, typography on non-library elements
Correct: Tailwind for layout, PrimeVue components used natively
<template>
<!-- Tailwind handles the page layout -->
<div class="mx-auto max-w-4xl space-y-6 p-6">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">Users</h1>
<!-- PrimeVue components used as-is -->
<DataTable :value="users" stripedRows paginator :rows="10">
<Column field="name" header="Name" sortable />
<Column field="email" header="Email" sortable />
<Column field="role" header="Role" />
</DataTable>
<!-- Tailwind for spacing between PrimeVue components -->
<div class="flex gap-3">
<Button label="Add User" icon="pi pi-plus" />
<Button label="Export" icon="pi pi-download" severity="secondary" outlined />
</div>
</div>
</template>
INCORRECT: Using Tailwind to restyle PrimeVue components
<template>
<!-- WRONG: Tailwind classes overriding PrimeVue button appearance -->
<Button label="Submit" class="rounded-full bg-indigo-600 px-8 py-3 shadow-xl" />
</template>
DataTable Best Practices
Correct: Typed DataTable with Composition API
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
interface User {
id: number
name: string
email: string
role: string
status: 'active' | 'inactive'
}
const users = ref<User[]>([])
const loading = ref(true)
const selectedUsers = ref<User[]>([])
// Pagination
const first = ref(0)
const rows = ref(10)
// Sorting
const sortField = ref<string>('name')
const sortOrder = ref<1 | -1>(1)
onMounted(async () => {
loading.value = true
try {
users.value = await fetchUsers()
} finally {
loading.value = false
}
})
</script>
<template>
<DataTable
v-model:selection="selectedUsers"
:value="users"
:loading="loading"
:paginator="true"
:rows="rows"
:first="first"
:sortField="sortField"
:sortOrder="sortOrder"
dataKey="id"
stripedRows
removableSort
@page="(e) => first = e.first"
@sort="(e) => { sortField = e.sortField; sortOrder = e.sortOrder }"
>
<Column selectionMode="multiple" headerStyle="width: 3rem" />
<Column field="name" header="Name" sortable />
<Column field="email" header="Email" sortable />
<Column field="role" header="Role" sortable />
<Column field="status" header="Status">
<template #body="{ data }">
<span
:class="[
'px-2 py-1 rounded-full text-xs font-medium',
data.status === 'active'
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'
]"
>
{{ data.status }}
</span>
</template>
</Column>
</DataTable>
</template>
Form Components Pattern
Correct: Form with validation using PrimeVue
<script setup lang="ts">
import { ref, computed } from 'vue'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import Dropdown from 'primevue/dropdown'
import Button from 'primevue/button'
import Message from 'primevue/message'
interface FormData {
email: string
password: string
role: string | null
}
const formData = ref<FormData>({
email: '',
password: '',
role: null
})
const errors = ref<Partial<Record<keyof FormData, string>>>({})
const submitted = ref(false)
const roles = [
{ label: 'Admin', value: 'admin' },
{ label: 'User', value: 'user' },
{ label: 'Guest', value: 'guest' }
]
const isValid = computed(() => {
return Object.keys(errors.value).length === 0
})
function validate(): boolean {
errors.value = {}
if (!formData.value.email) {
errors.value.email = 'Email is required'
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.value.email)) {
errors.value.email = 'Invalid email format'
}
if (!formData.value.password) {
errors.value.password = 'Password is required'
} else if (formData.value.password.length < 8) {
errors.value.password = 'Password must be at least 8 characters'
}
if (!formData.value.role) {
errors.value.role = 'Role is required'
}
return Object.keys(errors.value).length === 0
}
function handleSubmit() {
submitted.value = true
if (validate()) {
// Submit form
console.log('Form submitted:', formData.value)
}
}
</script>
<template>
<form @submit.prevent="handleSubmit" class="space-y-4">
<div class="flex flex-col gap-2">
<label for="email" class="font-medium">Email</label>
<InputText
id="email"
v-model="formData.email"
:class="{ 'p-invalid': errors.email }"
aria-describedby="email-error"
/>
<Message v-if="errors.email" severity="error" :closable="false">
{{ errors.email }}
</Message>
</div>
<div class="flex flex-col gap-2">
<label for="password" class="font-medium">Password</label>
<Password
id="password"
v-model="formData.password"
:class="{ 'p-invalid': errors.password }"
toggleMask
:feedback="false"
aria-describedby="password-error"
/>
<Message v-if="errors.password" severity="error" :closable="false">
{{ errors.password }}
</Message>
</div>
<div class="flex flex-col gap-2">
<label for="role" class="font-medium">Role</label>
<Dropdown
id="role"
v-model="formData.role"
:options="roles"
optionLabel="label"
optionValue="value"
placeholder="Select a role"
:class="{ 'p-invalid': errors.role }"
aria-describedby="role-error"
/>
<Message v-if="errors.role" severity="error" :closable="false">
{{ errors.role }}
</Message>
</div>
<Button type="submit" label="Submit" class="w-full" />
</form>
</template>
Dialog & Overlay Patterns
Correct: Confirmation dialog with composable
// composables/useConfirmDialog.ts
import { useConfirm } from 'primevue/useconfirm'
export function useConfirmDialog() {
const confirm = useConfirm()
function confirmDelete(
message: string,
onAccept: () => void,
onReject?: () => void
) {
confirm.require({
message,
header: 'Confirm Delete',
icon: 'pi pi-exclamation-triangle',
rejectClass: 'p-button-secondary p-button-outlined',
acceptClass: 'p-button-danger',
rejectLabel: 'Cancel',
acceptLabel: 'Delete',
accept: onAccept,
reject: onReject
})
}
function confirmAction(options: {
message: string
header: string
onAccept: () => void
onReject?: () => void
}) {
confirm.require({
message: options.message,
header: options.header,
icon: 'pi pi-info-circle',
rejectClass: 'p-button-secondary p-button-outlined',
acceptClass: 'p-button-primary',
accept: options.onAccept,
reject: options.onReject
})
}
return {
confirmDelete,
confirmAction
}
}
Usage:
<script setup lang="ts">
import { useConfirmDialog } from '@/composables/useConfirmDialog'
import ConfirmDialog from 'primevue/confirmdialog'
const { confirmDelete } = useConfirmDialog()
function handleDelete(item: Item) {
confirmDelete(
`Are you sure you want to delete "${item.name}"?`,
() => deleteItem(item.id)
)
}
</script>
<template>
<ConfirmDialog />
<Button label="Delete" severity="danger" @click="handleDelete(item)" />
</template>
Toast Notifications
Correct: Toast service with composable
// composables/useNotifications.ts
import { useToast } from 'primevue/usetoast'
export function useNotifications() {
const toast = useToast()
function success(summary: string, detail?: string) {
toast.add({
severity: 'success',
summary,
detail,
life: 3000
})
}
function error(summary: string, detail?: string) {
toast.add({
severity: 'error',
summary,
detail,
life: 5000
})
}
function warn(summary: string, detail?: string) {
toast.add({
severity: 'warn',
summary,
detail,
life: 4000
})
}
function info(summary: string, detail?: string) {
toast.add({
severity: 'info',
summary,
detail,
life: 3000
})
}
return { success, error, warn, info }
}
Accessibility Best Practices
PrimeVue components are WCAG 2.0 compliant. Ensure proper usage:
Correct: Accessible form fields
<template>
<div class="flex flex-col gap-2">
<label :for="id" class="font-medium">
{{ label }}
<span v-if="required" class="text-red-500" aria-hidden="true">*</span>
</label>
<InputText
:id="id"
v-model="modelValue"
:aria-required="required"
:aria-invalid="!!error"
:aria-describedby="error ? `${id}-error` : undefined"
/>
<small
v-if="error"
:id="`${id}-error`"
class="text-red-500"
role="alert"
>
{{ error }}
</small>
</div>
</template>
Lazy Loading Components
Correct: Async component loading for large PrimeVue components
// components/lazy/index.ts
import { defineAsyncComponent } from 'vue'
export const LazyDataTable = defineAsyncComponent({
loader: () => import('primevue/datatable'),
loadingComponent: () => import('@/components/ui/TableSkeleton.vue'),
delay: 200
})
export const LazyEditor = defineAsyncComponent({
loader: () => import('primevue/editor'),
loadingComponent: () => import('@/components/ui/EditorSkeleton.vue'),
delay: 200
})
export const LazyChart = defineAsyncComponent({
loader: () => import('primevue/chart'),
loadingComponent: () => import('@/components/ui/ChartSkeleton.vue'),
delay: 200
})
Anti-Patterns to Avoid
Don't Mutate Props
Incorrect:
<script setup>
const props = defineProps(['items'])
function addItem(item) {
props.items.push(item) // Never mutate props!
}
</script>
Correct:
<script setup>
const props = defineProps(['items'])
const emit = defineEmits(['update:items'])
function addItem(item) {
emit('update:items', [...props.items, item])
}
</script>
Don't Use v-if with v-for
Incorrect:
<template>
<div v-for="item in items" v-if="item.isActive" :key="item.id">
{{ item.name }}
</div>
</template>
Correct:
<script setup>
const activeItems = computed(() => items.value.filter(item => item.isActive))
</script>
<template>
<div v-for="item in activeItems" :key="item.id">
{{ item.name }}
</div>
</template>
Don't Store Derived State
Incorrect:
<script setup>
const items = ref([])
const itemCount = ref(0) // Derived state stored separately
watch(items, () => {
itemCount.value = items.value.length // Manually syncing
})
</script>
Correct:
<script setup>
const items = ref([])
const itemCount = computed(() => items.value.length) // Computed property
</script>
Don't Destructure Reactive Objects
Incorrect:
<script setup>
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = state // Loses reactivity!
</script>
Correct:
<script setup>
const state = reactive({ count: 0, name: 'Vue' })
const { count, name } = toRefs(state) // Preserves reactivity
</script>
Don't Concatenate Tailwind Class Names
Dynamic class concatenation breaks Tailwind's compiler and classes get purged in production:
Incorrect:
<script setup>
const color = ref('blue')
</script>
<template>
<!-- Classes will be purged in production! -->
<div :class="`bg-${color}-500 text-${color}-900`">
Content
</div>
</template>
Correct:
<script setup>
const color = ref<'blue' | 'green' | 'red'>('blue')
const colorClasses = computed(() => {
const colors = {
blue: 'bg-blue-500 text-blue-900',
green: 'bg-green-500 text-green-900',
red: 'bg-red-500 text-red-900'
}
return colors[color.value]
})
</script>
<template>
<div :class="colorClasses">
Content
</div>
</template>
Don't Overuse @apply
Excessive @apply usage defeats the purpose of utility-first CSS:
Incorrect:
/* styles.css */
.card {
@apply mx-auto max-w-md rounded-xl bg-white p-6 shadow-lg;
}
.card-title {
@apply text-xl font-semibold text-gray-900;
}
.card-description {
@apply mt-2 text-gray-600;
}
.card-button {
@apply mt-4 rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700;
}
Correct: Use Vue components instead
<!-- components/Card.vue -->
<template>
<div class="mx-auto max-w-md rounded-xl bg-white p-6 shadow-lg">
<h2 class="text-xl font-semibold text-gray-900">
<slot name="title" />
</h2>
<p class="mt-2 text-gray-600">
<slot name="description" />
</p>
<div class="mt-4">
<slot name="actions" />
</div>
</div>
</template>
Don't Use Conflicting Utilities
Applying multiple utilities that target the same CSS property causes unpredictable results:
Incorrect:
<template>
<!-- Both flex and grid target display property -->
<div class="flex grid">Content</div>
<!-- Multiple margin utilities conflict -->
<div class="m-4 mx-6">Content</div>
</template>
Correct:
<template>
<div :class="isGrid ? 'grid' : 'flex'">Content</div>
<!-- Use specific margin utilities -->
<div class="mx-6 my-4">Content</div>
</template>
Don't Ignore Accessibility
Always include proper accessibility attributes alongside visual styling:
Incorrect:
<template>
<button class="rounded bg-blue-600 p-2 text-white">
<IconX />
</button>
</template>
Correct:
<template>
<button
class="rounded bg-blue-600 p-2 text-white focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
aria-label="Close dialog"
>
<IconX aria-hidden="true" />
</button>
</template>
Don't Create Overly Long Class Strings
Break down complex class combinations into logical groups or components:
Incorrect:
<template>
<div class="mx-auto mt-8 flex max-w-4xl flex-col items-center justify-between gap-4 rounded-xl border border-gray-200 bg-white p-6 shadow-lg transition-all duration-300 hover:border-blue-500 hover:shadow-xl dark:border-gray-700 dark:bg-gray-800 sm:flex-row sm:gap-6 md:p-8 lg:gap-8">
<!-- 15+ utilities on one element -->
</div>
</template>
Correct: Extract to component or use computed
<script setup>
const containerClasses = [
// Layout
'mx-auto max-w-4xl flex flex-col sm:flex-row',
'items-center justify-between',
'gap-4 sm:gap-6 lg:gap-8',
// Spacing
'mt-8 p-6 md:p-8',
// Visual
'rounded-xl border bg-white shadow-lg',
'border-gray-200 dark:border-gray-700 dark:bg-gray-800',
// Interactive
'transition-all duration-300',
'hover:border-blue-500 hover:shadow-xl'
]
</script>
<template>
<div :class="containerClasses">
<slot />
</div>
</template>
Don't Override PrimeVue Styles with CSS
Using CSS overrides bypasses the design system and causes maintenance issues:
Incorrect:
/* styles.css - Avoid this approach */
.p-button {
background-color: #3b82f6 !important;
border-radius: 8px !important;
}
.p-datatable .p-datatable-thead > tr > th {
background: #f3f4f6 !important;
}
Correct: Use design tokens or PassThrough
// main.ts - Use design tokens
app.use(PrimeVue, {
theme: {
preset: Aura,
options: {
cssLayer: {
name: 'primevue',
order: 'tailwind-base, primevue, tailwind-utilities'
}
}
},
pt: {
button: {
root: { class: 'rounded-lg' }
}
}
})
Don't Import Entire PrimeVue Library
Importing everything bloats bundle size:
Incorrect:
// main.ts - Don't do this
import PrimeVue from 'primevue/config'
import * as PrimeVueComponents from 'primevue' // Imports everything!
Object.entries(PrimeVueComponents).forEach(([name, component]) => {
app.component(name, component)
})
Correct: Import only what you need
// main.ts - Tree-shakeable imports
import Button from 'primevue/button'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
app.component('Button', Button)
app.component('DataTable', DataTable)
app.component('Column', Column)
Don't Mix Styled and Unstyled Inconsistently
Mixing modes creates visual inconsistency:
Incorrect:
// main.ts
app.use(PrimeVue, {
unstyled: true // Global unstyled
})
// SomeComponent.vue - Using styled component anyway
<Button label="Click" /> // No styles applied, looks broken
Correct: Choose one approach consistently
// Option 1: Styled mode with PT customization
app.use(PrimeVue, {
theme: { preset: Aura },
pt: { /* global customizations */ }
})
// Option 2: Unstyled mode with complete PT styling
app.use(PrimeVue, {
unstyled: true,
pt: {
button: {
root: { class: 'px-4 py-2 bg-primary-600 text-white rounded-lg' }
}
// ... complete styling for all components
}
})
Don't Ignore Accessibility Attributes
PrimeVue provides accessibility out of the box, don't disable or ignore it:
Incorrect:
<template>
<!-- Missing aria attributes and label -->
<Button icon="pi pi-trash" @click="deleteItem" />
<!-- No error message association -->
<InputText v-model="email" :class="{ 'p-invalid': hasError }" />
<span class="error">Invalid email</span>
</template>
Correct: Maintain accessibility
<template>
<Button
icon="pi pi-trash"
aria-label="Delete item"
@click="deleteItem"
/>
<div class="flex flex-col gap-2">
<label for="email">Email</label>
<InputText
id="email"
v-model="email"
:class="{ 'p-invalid': hasError }"
:aria-invalid="hasError"
aria-describedby="email-error"
/>
<small id="email-error" v-if="hasError" class="text-red-500" role="alert">
Invalid email
</small>
</div>
</template>
Don't Hardcode PassThrough in Every Component
Repeating PT configuration across components creates duplication:
Incorrect:
<!-- ComponentA.vue -->
<Button :pt="{ root: { class: 'rounded-lg shadow-md' } }" />
<!-- ComponentB.vue -->
<Button :pt="{ root: { class: 'rounded-lg shadow-md' } }" />
<!-- ComponentC.vue -->
<Button :pt="{ root: { class: 'rounded-lg shadow-md' } }" />
Correct: Use global PT or wrapper components
// main.ts - Global configuration
app.use(PrimeVue, {
pt: {
button: {
root: { class: 'rounded-lg shadow-md' }
}
}
})
// Or use wrapper components (see Wrapper Components Pattern above)
Nuxt.js Specific Guidelines
When using Nuxt.js, follow these additional patterns:
- Auto-imports: Leverage Nuxt's auto-imports for Vue APIs and composables
- useFetch/useAsyncData: Use Nuxt's data fetching composables for SSR-compatible data loading
- definePageMeta: Use for page-level metadata and middleware
- Server routes: Use
server/api/for API endpoints - Runtime config: Use
useRuntimeConfig()for environment variables
References
Vue.js
- Vue.js Documentation
- Vue.js Style Guide
- Composition API FAQ
- VueUse - Collection of Vue Composition Utilities
- Nuxt Documentation
- Pinia Documentation
Tailwind CSS
- Tailwind CSS Documentation
- Styling with Utility Classes
- Tailwind CSS v4 Release
- Class Variance Authority (CVA)
- tailwind-merge
- prettier-plugin-tailwindcss
- Vue School - Tailwind CSS Fundamentals
PrimeVue
More from dedalus-erp-pas/foundation-skills
react-best-practices
Guide complet des bonnes pratiques React et Next.js couvrant l'optimisation des performances, l'architecture des composants, les patrons shadcn/ui, les animations Motion et les patrons modernes React 19+. À utiliser lors de l'écriture, la revue ou le refactoring de code React/Next.js. Se déclenche sur les tâches impliquant des composants React, des pages Next.js, du data fetching, des composants UI, des animations ou de l'amélioration de la qualité du code.
208playwright-skill
Automatisation complète du navigateur et tests web avec Playwright. Détecte automatiquement les serveurs de développement, gère le cycle de vie des serveurs, écrit des scripts de test propres dans /tmp. Tester des pages, remplir des formulaires, capturer des screenshots, vérifier le responsive design, valider l'UX, tester les flux de connexion, vérifier les liens, déboguer des webapps dynamiques, automatiser toute tâche navigateur. À utiliser quand l'utilisateur veut tester des sites web, automatiser des interactions navigateur, valider des fonctionnalités web ou effectuer tout test basé sur le navigateur.
170changelog-generator
Crée automatiquement des changelogs orientés utilisateur à partir des commits git en analysant l'historique, catégorisant les changements et transformant les commits techniques en notes de version claires et compréhensibles. Transforme des heures de rédaction manuelle en minutes de génération automatisée.
147postgres
Exécute des requêtes SQL en lecture seule sur plusieurs bases de données PostgreSQL. À utiliser pour : (1) interroger des bases PostgreSQL, (2) explorer les schémas/tables, (3) exécuter des requêtes SELECT pour l'analyse de données, (4) vérifier le contenu des bases. Supporte plusieurs connexions avec descriptions pour une sélection automatique intelligente. Bloque toutes les opérations d'écriture (INSERT, UPDATE, DELETE, DROP, etc.) par sécurité.
147article-extractor
Extraire le contenu propre d'articles depuis des URLs (billets de blog, articles, tutoriels) et sauvegarder en texte lisible. À utiliser quand l'utilisateur veut télécharger, extraire ou sauvegarder un article/billet de blog depuis une URL sans publicités, navigation ou encombrement.
146pptx
Création, édition et analyse de présentations. Quand Claude doit travailler avec des présentations (.pptx) pour : (1) Créer de nouvelles présentations, (2) Modifier ou éditer du contenu, (3) Travailler avec les mises en page, (4) Ajouter des commentaires ou notes du présentateur, ou toute autre tâche de présentation.
144