Pinia & Composables
Share form instances across components using Pinia stores or dedicated composables.
A form instance is a plain reactive object. You can store it in a Pinia store, return it from a composable, or pass it around like any other reactive value.
Scoped composable
The simplest pattern. One composable creates and owns an instance for a specific feature. Any component that calls it gets the same state when the composable is called within the same scope.
import { z } from 'zod'
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
message: z.string().min(10),
})
const form = useNotForm({
schema,
onSubmit: async (values) => {
await $fetch('/api/contact', { method: 'POST', body: values })
},
})
export function useContactForm() {
return { form }
}
<script setup lang="ts">
const { form } = useContactForm()
</script>
Pinia store
Store the instance in a Pinia store for global access across a complex feature.
import { defineStore } from 'pinia'
import { z } from 'zod'
const schema = z.object({
name: z.string().min(2),
email: z.string().email(),
password: z.string().min(8),
})
export const useSignupStore = defineStore('signup', () => {
const form = useNotForm({
schema,
initialValues: { name: '', email: '', password: '' },
onSubmit: async (values) => {
await $fetch('/api/signup', { method: 'POST', body: values })
await navigateTo('/dashboard')
},
})
return { form }
})
<script setup lang="ts">
const { form } = useSignupStore()
</script>
<template>
<NotField :form="form" path="name" v-slot="{ events }">
<input v-model="form.values.name" v-bind="events" />
<NotMessage :form="form" path="name" />
</NotField>
</template>
<script setup lang="ts">
const { form } = useSignupStore()
</script>
<template>
<NotField :form="form" path="email" v-slot="{ events }">
<input v-model="form.values.email" v-bind="events" />
<NotMessage :form="form" path="email" />
</NotField>
</template>
useNotForm uses reactive() internally. Pinia does not interfere with the arrays and Sets inside the instance.
Values, errors, touchedFields, and dirtyFields remain fully reactive. The instance itself is wrapped with markRaw to prevent Pinia from making it deeply reactive unnecessarily.Reading state outside components
The instance is plain reactive — read it in watchers, computed values, or other stores.
const store = useSignupStore()
// Watch for any dirty field
watch(() => store.form.isDirty.value, (dirty) => {
if (dirty) enableDraftSave()
})
// Check validity without running validation
if (store.form.isValid.value) {
proceedToPayment()
}