| title | Vue.js Component Patterns | ||||||
|---|---|---|---|---|---|---|---|
| description | Guides Kiro to write Vue components following modern Composition API and best practices | ||||||
| category | frameworks | ||||||
| tags |
|
||||||
| inclusion | always | ||||||
| applicableTo |
|
||||||
| requiredDependencies |
|
||||||
| filePatterns |
|
||||||
| version | 1.0.0 |
Kiro writes clean, maintainable Vue components using the Composition API and modern Vue 3 patterns. This steering document ensures components are well-structured, properly typed, and follow Vue conventions.
Consistent organization: Components follow script setup pattern
<!-- Kiro will write: -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import type { User } from '@/types/user';
interface Props {
userId: string;
showDetails?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
showDetails: true
});
const emit = defineEmits<{
update: [user: User];
delete: [userId: string];
}>();
const user = ref<User | null>(null);
const isLoading = ref(true);
const displayName = computed(() => {
return user.value ? `${user.value.firstName} ${user.value.lastName}` : '';
});
const fetchUser = async () => {
isLoading.value = true;
try {
const response = await fetch(`/api/users/${props.userId}`);
user.value = await response.json();
} finally {
isLoading.value = false;
}
};
const handleUpdate = () => {
if (user.value) {
emit('update', user.value);
}
};
onMounted(() => {
fetchUser();
});
</script>
<template>
<div class="user-profile">
<div v-if="isLoading" class="loading">Loading...</div>
<div v-else-if="user" class="user-content">
<h2>{{ displayName }}</h2>
<p v-if="showDetails">{{ user.email }}</p>
<button @click="handleUpdate">Update</button>
</div>
<div v-else class="error">User not found</div>
</div>
</template>
<style scoped>
.user-profile {
padding: 1rem;
}
.loading {
color: #666;
}
</style>
<!-- Not: -->
<script>
export default {
props: ['userId', 'showDetails'],
data() {
return {
user: null,
isLoading: true
};
},
computed: {
displayName() {
return this.user ? `${this.user.firstName} ${this.user.lastName}` : '';
}
},
mounted() {
this.fetchUser();
},
methods: {
async fetchUser() {
this.isLoading = true;
const response = await fetch(`/api/users/${this.userId}`);
this.user = await response.json();
this.isLoading = false;
}
}
};
</script>
Clear prop types: TypeScript interfaces for props with defaults
<!-- Kiro will write: -->
<script setup lang="ts">
interface Props {
title: string;
count?: number;
variant?: 'primary' | 'secondary' | 'danger';
disabled?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
count: 0,
variant: 'primary',
disabled: false
});
</script>
<template>
<div :class="`card card-${variant}`">
<h3>{{ title }}</h3>
<span class="count">{{ count }}</span>
</div>
</template>
<!-- Not: -->
<script>
export default {
props: {
title: String,
count: Number,
variant: String,
disabled: Boolean
}
};
</script>
Descriptive state names: Clear naming for reactive variables
<!-- Kiro will write: -->
<script setup lang="ts">
import { ref, reactive } from 'vue';
const searchQuery = ref('');
const searchResults = ref<Result[]>([]);
const isSearching = ref(false);
const formData = reactive({
email: '',
password: '',
rememberMe: false
});
const handleSearch = async () => {
isSearching.value = true;
try {
const results = await searchAPI(searchQuery.value);
searchResults.value = results;
} finally {
isSearching.value = false;
}
};
</script>
<template>
<div class="search-form">
<input v-model="searchQuery" placeholder="Search..." />
<button @click="handleSearch" :disabled="isSearching">
{{ isSearching ? 'Searching...' : 'Search' }}
</button>
<ul v-if="searchResults.length">
<li v-for="result in searchResults" :key="result.id">
{{ result.title }}
</li>
</ul>
</div>
</template>
<!-- Not: -->
<script setup>
const query = ref('');
const results = ref([]);
const loading = ref(false);
const search = async () => {
loading.value = true;
results.value = await searchAPI(query.value);
loading.value = false;
};
</script>
Clear computed logic: Derived state with proper typing
<!-- Kiro will write: -->
<script setup lang="ts">
import { ref, computed } from 'vue';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
const cartItems = ref<CartItem[]>([]);
const totalItems = computed(() => {
return cartItems.value.reduce((sum, item) => sum + item.quantity, 0);
});
const totalPrice = computed(() => {
return cartItems.value.reduce((sum, item) => sum + (item.price * item.quantity), 0);
});
const formattedTotal = computed(() => {
return `$${totalPrice.value.toFixed(2)}`;
});
</script>
<template>
<div class="cart-summary">
<p>Items: {{ totalItems }}</p>
<p>Total: {{ formattedTotal }}</p>
</div>
</template>
<!-- Not: -->
<script setup>
const cartItems = ref([]);
const total = computed(() => {
let sum = 0;
for (let i = 0; i < cartItems.value.length; i++) {
sum += cartItems.value[i].price * cartItems.value[i].quantity;
}
return '$' + sum.toFixed(2);
});
</script>
Proper lifecycle management: Clear setup and cleanup
<!-- Kiro will write: -->
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
const activityLog = ref<Activity[]>([]);
let subscription: Subscription | null = null;
const subscribeToActivity = (userId: string) => {
subscription = activityStream.subscribe(userId, (activity) => {
activityLog.value.push(activity);
});
};
const unsubscribeFromActivity = () => {
if (subscription) {
subscription.unsubscribe();
subscription = null;
}
};
onMounted(() => {
subscribeToActivity(props.userId);
});
onUnmounted(() => {
unsubscribeFromActivity();
});
watch(() => props.userId, (newUserId, oldUserId) => {
if (newUserId !== oldUserId) {
unsubscribeFromActivity();
subscribeToActivity(newUserId);
}
});
</script>
<!-- Not: -->
<script setup>
const activityLog = ref([]);
onMounted(() => {
activityStream.subscribe(props.userId, (activity) => {
activityLog.value.push(activity);
});
});
// Missing cleanup
</script>
Reusable logic: Extract common patterns into composables
// Kiro will write:
// composables/useApi.ts
import { ref, type Ref } from 'vue';
interface UseApiReturn<T> {
data: Ref<T | null>;
isLoading: Ref<boolean>;
error: Ref<Error | null>;
refetch: () => Promise<void>;
}
export function useApi<T>(url: string): UseApiReturn<T> {
const data = ref<T | null>(null);
const isLoading = ref(true);
const error = ref<Error | null>(null);
const fetchData = async () => {
isLoading.value = true;
error.value = null;
try {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch');
data.value = await response.json();
} catch (err) {
error.value = err as Error;
} finally {
isLoading.value = false;
}
};
fetchData();
return {
data,
isLoading,
error,
refetch: fetchData
};
}
// Usage:
<script setup lang="ts">
import { useApi } from '@/composables/useApi';
import type { User } from '@/types/user';
const { data: users, isLoading, error } = useApi<User[]>('/api/users');
</script>
<template>
<div v-if="isLoading">Loading...</div>
<div v-else-if="error">Error: {{ error.message }}</div>
<ul v-else>
<li v-for="user in users" :key="user.id">{{ user.name }}</li>
</ul>
</template>
// Not:
<script setup>
const users = ref([]);
const loading = ref(true);
onMounted(async () => {
const response = await fetch('/api/users');
users.value = await response.json();
loading.value = false;
});
</script>Consistent naming: Use handle prefix for event handlers
<!-- Kiro will write: -->
<script setup lang="ts">
import { ref } from 'vue';
const email = ref('');
const password = ref('');
const isSubmitting = ref(false);
const handleEmailChange = (event: Event) => {
const target = event.target as HTMLInputElement;
email.value = target.value;
};
const handleSubmit = async () => {
if (isSubmitting.value) return;
isSubmitting.value = true;
try {
await login(email.value, password.value);
} finally {
isSubmitting.value = false;
}
};
</script>
<template>
<form @submit.prevent="handleSubmit">
<input
type="email"
:value="email"
@input="handleEmailChange"
/>
<input
type="password"
v-model="password"
/>
<button type="submit" :disabled="isSubmitting">
{{ isSubmitting ? 'Logging in...' : 'Login' }}
</button>
</form>
</template>
<!-- Not: -->
<script setup>
const email = ref('');
const password = ref('');
const submit = async () => {
await login(email.value, password.value);
};
</script>
<template>
<form @submit.prevent="submit">
<input type="email" v-model="email" />
<input type="password" v-model="password" />
<button type="submit">Login</button>
</form>
</template>
Small, focused components: Break down complex UIs
<!-- Kiro will write: -->
<!-- Card.vue -->
<script setup lang="ts">
interface Props {
variant?: 'default' | 'elevated' | 'outlined';
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default'
});
</script>
<template>
<div :class="`card card-${variant}`">
<slot />
</div>
</template>
<!-- CardHeader.vue -->
<template>
<div class="card-header">
<slot />
</div>
</template>
<!-- CardBody.vue -->
<template>
<div class="card-body">
<slot />
</div>
</template>
<!-- Usage: UserCard.vue -->
<script setup lang="ts">
import Card from '@/components/Card.vue';
import CardHeader from '@/components/CardHeader.vue';
import CardBody from '@/components/CardBody.vue';
import type { User } from '@/types/user';
interface Props {
user: User;
}
const props = defineProps<Props>();
const emit = defineEmits<{
edit: [userId: string];
}>();
const handleEdit = () => {
emit('edit', props.user.id);
};
</script>
<template>
<Card variant="elevated">
<CardHeader>
<h3>{{ user.name }}</h3>
</CardHeader>
<CardBody>
<p>{{ user.email }}</p>
<p>{{ user.bio }}</p>
<button @click="handleEdit">Edit</button>
</CardBody>
</Card>
</template>
<!-- Not: -->
<template>
<div class="card">
<div class="card-header">
<h3>{{ user.name }}</h3>
</div>
<div class="card-body">
<p>{{ user.email }}</p>
<p>{{ user.bio }}</p>
<button @click="$emit('edit', user.id)">Edit</button>
</div>
</div>
</template>
-
Options API confusion by using modern Composition API
-
Type errors with TypeScript integration
-
Memory leaks from missing cleanup in lifecycle hooks
-
Prop drilling through proper component composition
-
Inconsistent patterns across components
-
Hard-to-test components from poor separation of concerns
<!-- Before: -->
<script>
export default {
props: ['userId'],
data() {
return {
user: null,
posts: [],
loading: true
};
},
mounted() {
fetch(`/api/users/${this.userId}`).then(r => r.json()).then(u => this.user = u);
fetch(`/api/users/${this.userId}/posts`).then(r => r.json()).then(p => this.posts = p);
this.loading = false;
},
template: `
<div v-if="loading">Loading...</div>
<div v-else>
<h1>{{ user.name }}</h1>
<div v-for="post in posts" :key="post.id">{{ post.title }}</div>
</div>
`
};
</script>
<!-- After: -->
<script setup lang="ts">
import { useApi } from '@/composables/useApi';
import UserHeader from '@/components/UserHeader.vue';
import PostList from '@/components/PostList.vue';
import type { User, Post } from '@/types';
interface Props {
userId: string;
}
const props = defineProps<Props>();
const { data: user, isLoading: isLoadingUser } = useApi<User>(`/api/users/${props.userId}`);
const { data: posts, isLoading: isLoadingPosts } = useApi<Post[]>(`/api/users/${props.userId}/posts`);
const isLoading = computed(() => isLoadingUser.value || isLoadingPosts.value);
</script>
<template>
<div class="user-dashboard">
<div v-if="isLoading" class="loading">
<LoadingSpinner />
</div>
<div v-else-if="user" class="content">
<UserHeader :user="user" />
<PostList :posts="posts || []" />
</div>
<div v-else class="error">
User not found
</div>
</div>
</template>
<style scoped>
.user-dashboard {
padding: 1rem;
}
</style>
This is a starting point for Vue component patterns. You can customize by:
-
Adding state management patterns (Pinia, Vuex)
-
Including routing conventions (Vue Router)
-
Adding form handling patterns (VeeValidate)
-
Incorporating testing patterns (Vue Test Utils)
-
TypeScript Formatting - TypeScript conventions
-
JavaScript Formatting - JavaScript style
Want to enforce these patterns automatically? Consider these tools:
# ESLint with Vue plugin
npm install --save-dev eslint eslint-plugin-vue
# TypeScript for type checking
npm install --save-dev typescript vue-tsc
# Vue Test Utils
npm install --save-dev @vue/test-utils vitest
Note: These tools help enforce patterns but aren't required for the steering document to work.