// Skill for creating and editing Vue.js components following Prowi conventions. Use when writing Vue files, creating components, or refactoring frontend code. Enforces modern patterns like defineModel(), TypeScript, and Composition API.
| name | vue-writer |
| description | Skill for creating and editing Vue.js components following Prowi conventions. Use when writing Vue files, creating components, or refactoring frontend code. Enforces modern patterns like defineModel(), TypeScript, and Composition API. |
You are an expert Vue.js developer for the Prowi application. Your role is to create modern, maintainable Vue components that follow established conventions and avoid legacy patterns.
This is the #1 mistake to avoid! Many existing components use the old pattern, but ALL new code must use defineModel().
<script setup>
// BAD - Using modelValue prop + manual emit
const props = defineProps({
modelValue: {
type: String,
default: ''
}
});
const emit = defineEmits(['update:modelValue']);
// Manual handling with watch or methods
const updateValue = (value) => {
emit('update:modelValue', value);
};
</script>
<script setup lang="ts">
// GOOD - Using defineModel()
const model = defineModel<string>({ default: '' });
// That's it! No manual emit, no watch, just use it directly
</script>
<template>
<input v-model="model" />
</template>
Why defineModel() is better:
STRICT ORDER - Always follow this structure:
<script setup lang="ts">
// 1. Imports
import { ref, computed } from 'vue';
import Button from '@/Components/App/Forms/Button.vue';
// 2. Props
const props = defineProps({
// props here
});
// 3. Models (for two-way binding)
const model = defineModel<string>();
// 4. Emits
const emit = defineEmits(['save', 'cancel']);
// 5. Reactive state
const loading = ref(false);
// 6. Computed properties
const isValid = computed(() => model.value?.length > 0);
// 7. Methods
function handleSubmit() {
emit('save', model.value);
}
</script>
<template>
<!-- HTML here -->
</template>
<style scoped>
/* Scoped styles here */
</style>
Key Requirements:
<script setup lang="ts"> for TypeScript (REQUIRED for new components)<style scoped> to prevent style leakage<script setup lang="ts">
// Simple model
const model = defineModel<string>();
// With default value
const checked = defineModel<boolean>({ default: false });
// With required
const required = defineModel<number>({ required: true });
</script>
<template>
<input v-model="model" />
<input type="checkbox" v-model="checked" />
</template>
<script setup lang="ts">
// Named models for multiple v-model bindings
const firstName = defineModel<string>('firstName');
const lastName = defineModel<string>('lastName');
</script>
<template>
<input v-model="firstName" placeholder="First name" />
<input v-model="lastName" placeholder="Last name" />
</template>
<template>
<!-- Single model -->
<MyInput v-model="userName" />
<!-- Multiple models -->
<MyForm
v-model:firstName="user.firstName"
v-model:lastName="user.lastName"
/>
</template>
Always use full object syntax with type, required, and default:
<script setup lang="ts">
import type { PropType } from 'vue';
import type { User } from '@/types/generated';
const props = defineProps({
// Simple types
title: {
type: String,
required: true,
},
// With default
isActive: {
type: Boolean,
default: false,
},
// Multiple types
value: {
type: [String, Number],
default: '',
},
// Complex types with PropType
user: {
type: Object as PropType<User>,
required: true,
},
// Array of specific type
items: {
type: Array as PropType<User[]>,
default: () => [],
},
// Object with specific shape
config: {
type: Object as PropType<{ enabled: boolean; count: number }>,
default: () => ({ enabled: true, count: 0 }),
},
});
</script>
Type Locations:
resources/js/types/generated.d.ts (models, enums, data classes)resources/js/types/enums.tsresources/js/types/ziggy.ts<script setup lang="ts">
import { ref, computed } from 'vue';
import type { PropType } from 'vue';
import type { User, CustomerUser } from '@/types/generated';
const props = defineProps({
user: {
type: Object as PropType<User>,
required: true,
},
});
const model = defineModel<string | null>({ default: null });
const count = ref<number>(0);
const users = ref<CustomerUser[]>([]);
const formattedName = computed<string>(() => {
return props.user.name.toUpperCase();
});
function updateCount(value: number): void {
count.value = value;
}
</script>
// Vue types
import type { PropType, ComputedRef, Ref } from 'vue';
// Generated backend types
import type {
User,
Customer,
CustomerUser,
// ... other models
} from '@/types/generated';
// Enums
import {
UserStatusEnum,
PaymentStatusEnum
} from '@/types/enums';
This project uses OLD Inertia version - use these imports:
// ✅ CORRECT - Old imports
import { Inertia } from '@inertiajs/inertia';
import { usePage } from '@inertiajs/inertia-vue3';
import { useForm } from '@inertiajs/inertia-vue3';
import { Link } from '@inertiajs/inertia-vue3';
// ❌ WRONG - New imports (don't exist in v0.11)
import { router } from '@inertiajs/vue3'; // DON'T USE
import { usePage } from '@inertiajs/vue3'; // DON'T USE
<script setup lang="ts">
import { usePage } from '@inertiajs/inertia-vue3';
// ✅ GOOD - Use defineProps for component-specific data
const props = defineProps({
user: {
type: Object,
required: true,
},
items: {
type: Array,
required: true,
},
});
// ✅ GOOD - Use usePage() ONLY for global Inertia properties
const page = usePage();
const currentUser = page.props.value.auth.user;
const flashMessage = page.props.value.flash.message;
// ❌ BAD - Don't use usePage() for component props
// const { user, items } = usePage().props.value; // WRONG!
</script>
<script setup lang="ts">
import { useForm } from '@inertiajs/inertia-vue3';
const props = defineProps({
user: {
type: Object,
required: true,
},
});
const form = useForm({
name: props.user.name,
email: props.user.email,
});
function submit() {
form.put(route('users.update', props.user.id), {
onSuccess: () => {
// Handle success
},
onError: () => {
// Handle errors
},
});
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model="form.name" />
<div v-if="form.errors.name" class="text-red-500">
{{ form.errors.name }}
</div>
<button type="submit" :disabled="form.processing">
Save
</button>
</form>
</template>
<template>
<div class="container">
<button class="btn-primary">Click me</button>
</div>
</template>
<style scoped>
/* Scoped to this component only */
.container {
@apply max-w-4xl mx-auto p-4;
}
.btn-primary {
@apply bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded;
}
/* Deep selector for child components */
:deep(.child-class) {
@apply text-gray-700;
}
</style>
<template>
<!-- ✅ GOOD - Grouped by purpose -->
<div class="flex items-center justify-between gap-4 p-4 bg-white rounded-lg shadow-md">
<span class="text-lg font-bold text-gray-900">Title</span>
<button class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded">
Action
</button>
</div>
<!-- ❌ BAD - Random order -->
<div class="p-4 rounded-lg flex bg-white items-center shadow-md justify-between gap-4">
<span class="font-bold text-gray-900 text-lg">Title</span>
</div>
</template>
<!-- Parent.vue -->
<script setup lang="ts">
import { ref } from 'vue';
import ChildComponent from './ChildComponent.vue';
const selectedItem = ref<string | null>(null);
function handleSelection(item: string) {
selectedItem.value = item;
}
</script>
<template>
<ChildComponent
:items="['A', 'B', 'C']"
@itemSelected="handleSelection"
/>
</template>
<!-- ChildComponent.vue -->
<script setup lang="ts">
const props = defineProps({
items: {
type: Array as PropType<string[]>,
required: true,
},
});
const emit = defineEmits<{
itemSelected: [item: string]
}>();
function selectItem(item: string) {
emit('itemSelected', item);
}
</script>
<template>
<ul>
<li
v-for="item in items"
:key="item"
@click="selectItem(item)"
>
{{ item }}
</li>
</ul>
</template>
Reduce nesting with early returns:
<script setup lang="ts">
// ✅ GOOD - Early exit
function processUser(user: User | null) {
if (!user) {
return;
}
if (!user.isActive) {
return;
}
// Main logic here
updateUserData(user);
}
// ❌ BAD - Deep nesting
function processUser(user: User | null) {
if (user) {
if (user.isActive) {
// Main logic deeply nested
updateUserData(user);
}
}
}
</script>
Migrate when:
Keep Options API when:
<!-- BEFORE - Options API -->
<script>
export default {
props: ['modelValue', 'placeholder'],
emits: ['update:modelValue'],
data() {
return {
internalValue: this.modelValue
};
},
watch: {
modelValue(newValue) {
this.internalValue = newValue;
}
},
methods: {
updateValue(value) {
this.internalValue = value;
this.$emit('update:modelValue', value);
}
}
};
</script>
<!-- AFTER - Composition API with defineModel -->
<script setup lang="ts">
const props = defineProps({
placeholder: {
type: String,
default: '',
},
});
const model = defineModel<string>({ default: '' });
</script>
<template>
<input
v-model="model"
:placeholder="placeholder"
/>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import type { PropType } from 'vue';
import type { User } from '@/types/generated';
import { UserStatusEnum } from '@/types/enums';
import { useForm } from '@inertiajs/inertia-vue3';
import Button from '@/Components/App/Forms/Button.vue';
import Input from '@/Components/App/Forms/Input.vue';
// Props
const props = defineProps({
user: {
type: Object as PropType<User>,
required: true,
},
isEditable: {
type: Boolean,
default: false,
},
});
// Models
const isModalOpen = defineModel<boolean>('isModalOpen', { default: false });
// Emits
const emit = defineEmits<{
saved: [user: User],
cancelled: []
}>();
// State
const form = useForm({
name: props.user.name,
email: props.user.email,
status: props.user.status,
});
// Computed
const isActive = computed(() => {
return props.user.status === UserStatusEnum.ACTIVE;
});
const canSubmit = computed(() => {
return form.name.length > 0 &&
form.email.length > 0 &&
!form.processing;
});
// Methods
function handleSubmit(): void {
form.put(route('users.update', props.user.id), {
onSuccess: () => {
isModalOpen.value = false;
emit('saved', form.data());
},
onError: () => {
console.error('Failed to save user');
},
});
}
function handleCancel(): void {
form.reset();
isModalOpen.value = false;
emit('cancelled');
}
</script>
<template>
<div class="max-w-2xl mx-auto p-6">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<h2 class="text-2xl font-bold text-gray-900">
Edit User
</h2>
<span
class="px-3 py-1 text-sm font-medium rounded-full"
:class="{
'bg-green-100 text-green-800': isActive,
'bg-gray-100 text-gray-800': !isActive,
}"
>
{{ isActive ? 'Active' : 'Inactive' }}
</span>
</div>
<!-- Form -->
<form @submit.prevent="handleSubmit" class="space-y-4">
<!-- Name Input -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<Input
v-model="form.name"
type="text"
:disabled="!isEditable"
:error="form.errors.name"
/>
<div v-if="form.errors.name" class="mt-1 text-sm text-red-600">
{{ form.errors.name }}
</div>
</div>
<!-- Email Input -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<Input
v-model="form.email"
type="email"
:disabled="!isEditable"
:error="form.errors.email"
/>
<div v-if="form.errors.email" class="mt-1 text-sm text-red-600">
{{ form.errors.email }}
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-end gap-3 pt-4">
<Button
type="button"
variant="secondary"
@click="handleCancel"
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
:disabled="!canSubmit"
:loading="form.processing"
>
Save Changes
</Button>
</div>
</form>
</div>
</template>
<style scoped>
/* Component-specific styles if needed */
</style>
<!-- 1. Using modelValue prop instead of defineModel -->
<script setup>
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
</script>
<!-- 2. Missing TypeScript -->
<script setup> <!-- No lang="ts" -->
const props = defineProps({
user: Object, // No PropType
});
</script>
<!-- 3. Wrong Inertia imports -->
<script setup lang="ts">
import { router } from '@inertiajs/vue3'; // Wrong version!
</script>
<!-- 4. Using usePage() for component props -->
<script setup lang="ts">
const { user, items } = usePage().props.value; // Wrong!
</script>
<!-- 5. No scoped styles -->
<style> <!-- Not scoped! -->
.my-class { }
</style>
<!-- 1. Use defineModel -->
<script setup lang="ts">
const model = defineModel<string>();
</script>
<!-- 2. Include TypeScript -->
<script setup lang="ts">
import type { PropType } from 'vue';
const props = defineProps({
user: {
type: Object as PropType<User>,
required: true,
},
});
</script>
<!-- 3. Correct Inertia imports -->
<script setup lang="ts">
import { Inertia } from '@inertiajs/inertia';
import { usePage } from '@inertiajs/inertia-vue3';
</script>
<!-- 4. Use defineProps for component data -->
<script setup lang="ts">
const props = defineProps({
user: Object as PropType<User>,
items: Array as PropType<Item[]>,
});
</script>
<!-- 5. Use scoped styles -->
<style scoped>
.my-class { }
</style>
Before considering a component complete, verify:
<script setup lang="ts"> with TypeScriptdefineModel() for two-way binding (NOT modelValue prop)<style scoped> if styles are neededresources/js/types/generated.d.tsThe #1 mistake to avoid: Using modelValue prop pattern instead of defineModel().
If you see this in new code:
const props = defineProps(['modelValue']);
const emit = defineEmits(['update:modelValue']);
STOP and use this instead:
const model = defineModel<YourType>();
Your goal is to create clean, type-safe, modern Vue components that will be easy for future developers to maintain and extend.