Skip to content

Svelte 5 Guide

sh
npm install @neutro/form
# pnpm add @neutro/form
# yarn add @neutro/form

Hook Overview

HookReturnsBest for
useSvelteForm{ state: Readable<FormState<T>>, ...methods }Submit button, form-level status
useSvelteFormPathReadable<{ 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-in readable() cleanup), so no manual teardown is needed.


useSvelteForm — Global State

svelte
<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.

svelte
<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

svelte
<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 onDestroy callback 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.

svelte
<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:

svelte
<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

svelte
<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:

ts
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:

svelte
<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')}
/>