Svelte 5 Guide
npm install @neutro/form
# pnpm add @neutro/form
# yarn add @neutro/formHook Overview
| Hook | Returns | Best for |
|---|---|---|
useSvelteForm | { state: Readable<FormState<T>>, ...methods } | Submit button, form-level status |
useSvelteFormPath | Readable<{ value, fieldState: { error?, touched?, dirty? } }> | Individual field components |
Important: Call both hooks during component initialisation — not inside event handlers or
setTimeout. The returned stores automatically unsubscribe when the last reactive consumer detaches (Svelte's built-inreadable()cleanup), so no manual teardown is needed.
useSvelteForm — Global State
<script lang="ts">
import { createForm } from '@neutro/form/core'
import { useSvelteForm } from '@neutro/form/adapters/svelte'
type LoginValues = { email: string; password: string }
const form = createForm<LoginValues>({
initialValues: { email: '', password: '' },
validator: (values) => {
const errors: Record<string, string> = {}
if (!values.email.includes('@')) errors.email = 'Invalid email'
if (values.password.length < 8) errors.password = 'Min 8 characters'
return errors
},
})
// Destructure state (the Svelte readable) — use $state in templates
const { state } = useSvelteForm(form)
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
await form.validate()
}
</script>
<form on:submit={handleSubmit}>
<input
value={$state.values.email}
on:input={(e) => form.set('email', e.currentTarget.value, { touch: true })}
/>
{#if $state.errors.email}
<span class="error">{$state.errors.email}</span>
{/if}
<input
type="password"
value={$state.values.password}
on:input={(e) => form.set('password', e.currentTarget.value, { touch: true })}
/>
{#if $state.errors.password}
<span class="error">{$state.errors.password}</span>
{/if}
<button type="submit" disabled={$state.isSubmitting}>
{$state.isSubmitting ? 'Signing in…' : 'Sign in'}
</button>
</form>useSvelteFormPath — Single Field Store
useSvelteFormPath returns a readable store of { value, fieldState }. Use the $field shorthand in templates: $field.value for the field's current value, $field.fieldState?.error / $field.fieldState?.touched for validation state.
<script lang="ts">
import { createForm } from '@neutro/form/core'
import { useSvelteFormPath } from '@neutro/form/adapters/svelte'
export let form: ReturnType<typeof createForm>
export let path: string
export let label: string
// Call at component init — not inside an event handler
const field = useSvelteFormPath(form, path)
</script>
<label>
{label}
<input
value={$field.value}
on:input={(e) =>
form.set(path, e.currentTarget.value, { touch: true, validate: true })
}
/>
{#if $field.fieldState?.touched && $field.fieldState?.error}
<span class="error">{$field.fieldState?.error}</span>
{/if}
</label>Full Example with Zod
<script lang="ts">
import { createForm } from '@neutro/form/core'
import { zodAdapter } from '@neutro/form/core'
import { useSvelteForm, useSvelteFormPath } from '@neutro/form/adapters/svelte'
import { z } from 'zod'
const schema = z.object({
username: z.string().min(3, 'At least 3 characters'),
email: z.string().email('Invalid email'),
})
type Values = z.infer<typeof schema>
const form = createForm<Values>({
initialValues: { username: '', email: '' },
validator: zodAdapter(schema),
})
const { state } = useSvelteForm(form)
const username = useSvelteFormPath(form, 'username')
const email = useSvelteFormPath(form, 'email')
</script>
<form on:submit|preventDefault={() => form.validate()}>
<label>
Username
<input
value={$username.value}
on:input={(e) => form.set('username', e.currentTarget.value, { touch: true, validate: true })}
/>
{#if $username.fieldState?.touched && $username.fieldState?.error}
<span>{$username.fieldState?.error}</span>
{/if}
</label>
<label>
Email
<input
type="email"
value={$email.value}
on:input={(e) => form.set('email', e.currentTarget.value, { touch: true, validate: true })}
/>
{#if $email.fieldState?.touched && $email.fieldState?.error}
<span>{$email.fieldState?.error}</span>
{/if}
</label>
<button type="submit" disabled={$state.isSubmitting}>Save</button>
</form>Lifecycle Notes
- Both hooks register an
onDestroycallback to call the form's unsubscribe function. This requires that they are called synchronously during component initialisation. - If you need to create the form and its stores lazily (e.g. based on a prop that arrives asynchronously), create the form instance outside the component and pass it in as a prop.
- The returned stores are standard Svelte readable stores — you can derive from them with
derived()exactly as you would any other store.
Handling Server Errors
Use form.setErrors() inside your submit handler to feed API validation errors back into form state. They surface in $state.errors and clear on the next validation run — no extra wiring required.
<script lang="ts">
import { createForm } from '@neutro/form/core'
import { useSvelteForm } from '@neutro/form/adapters/svelte'
const form = createForm({
initialValues: { email: '', username: '' },
rules: { email: ['required', 'email'], username: 'required' },
})
const { state } = useSvelteForm(form)
async function handleSubmit(e: SubmitEvent) {
e.preventDefault()
const valid = await form.validate()
if (!valid) return
const res = await fetch('/api/register', {
method: 'POST',
body: JSON.stringify(form.getPayload()),
})
if (!res.ok) {
const { errors } = await res.json()
form.setErrors(errors)
}
}
</script>
<form on:submit={handleSubmit}>
<input
value={$state.values.email}
on:input={(e) => form.set('email', e.currentTarget.value, { touch: true })}
/>
{#if $state.touched.email && $state.errors.email}
<span class="error">{$state.errors.email}</span>
{/if}
<input
value={$state.values.username}
on:input={(e) => form.set('username', e.currentTarget.value, { touch: true })}
/>
{#if $state.touched.username && $state.errors.username}
<span class="error">{$state.errors.username}</span>
{/if}
<button type="submit" disabled={$state.isSubmitting}>Register</button>
</form>Resetting a Single Field
Call form.resetField(path) to reset one field. The Svelte store updates reactively through the subscription:
<script>
import { createForm } from '@neutro/form/core'
import { useSvelteForm } from '@neutro/form/adapters/svelte'
const form = createForm({ initialValues: { email: '' } })
const { state, resetField } = useSvelteForm(form)
</script>
<input bind:value={$state.values.email} />
{#if $state.errors.email}
<button on:click={() => resetField('email')}>Reset</button>
{/if}useSvelteWatch — Observe Field Values
<script>
import { useSvelteWatch } from '@neutro/form/adapters/svelte'
const watched = useSvelteWatch(form, ['email', 'username'])
</script>
<p>{$watched.email} — {$watched.username}</p>useSvelteWatch returns a Readable store. Use the $watched shorthand in templates to auto-subscribe.
Validation Modes
Configure when validation triggers globally and per field via validationMode in createForm:
const form = createForm({
initialValues: { email: '', password: '' },
validationMode: {
default: 'onTouched',
fields: { password: 'onChange' },
},
})For Svelte reactive inputs, use form.getFieldMode(path) to wire the right events:
<script>
const { state, set, validate } = useSvelteForm(form)
function handleInput(path, value) {
set(path, value)
if (form.getFieldMode(path) === 'onChange') validate([path])
}
function handleBlur(path) {
const mode = form.getFieldMode(path)
if (mode === 'onBlur' || mode === 'onTouched') validate([path])
}
</script>
<input
value={$state.values.email}
on:input={e => handleInput('email', e.target.value)}
on:blur={() => handleBlur('email')}
/>