@opensky/remotes

Contact form

Inline validation with custom validators, plus tracked submission state. Try [email protected] (async validator), [email protected] (server-only issue), or the word spam in the message.

State: idle

What's going on here

The form opts each field into validation with a .validate spread, so issues only appear after a dirty field loses focus — and clear again the moment the value is valid. The email field carries an async validator (watch its pending flag show "Checking..."), the message field a sync one, and [email protected] demonstrates a server-only issue raised with invalid() inside the remote function: it can't be caught on blur, so it arrives through the issues state after a real submit.

Every field also spreads .persist, so a half-written message survives a reload. The submit button watches form.pending and form.delayed — the remote function sleeps for a second so you can see the delayed state kick in at 300ms.

Demo.svelte
<script lang="ts">
	import { enhancedForm } from '@opensky/remotes'

	import { contactForm } from './form.remote'
	import { contactSchema } from './schema'

	const form = enhancedForm(contactForm, {
		delayMs: 300,
		timeoutMs: 5000,
		// Fields opt into draft persistence below by spreading `.persist` —
		// reload mid-draft and the values come back
		persist: { maxAgeMs: 24 * 60 * 60 * 1000 }
	})

	// Async client-side validator — runs on blur, exposes `pending` while it waits
	form.fields.email.addValidator(async ({ value, issue }) => {
		await new Promise((resolve) => setTimeout(resolve, 900))

		if (value === '[email protected]') {
			return issue('Email is not allowed')
		}
	})

	// Sync client-side validator
	form.fields.message.addValidator(({ value, issue }) => {
		if (value?.includes('spam')) return issue('No spam please')
	})
</script>

<form
	class="demo"
	{...form.handlers}
	{...contactForm.preflight(contactSchema).enhance((instance) =>
		form.enhance(instance, {
			onReturn: ({ result }) => console.log('success', result),
			onIssues: () => console.log('validation issues'),
			onError: ({ error }) => console.error(error)
		})
	)}
>
	<label>
		Name
		<input
			{...contactForm.fields.name.as('text')}
			{...form.fields.name.validate}
			{...form.fields.name.persist}
			autocomplete="name"
			placeholder="Ada Lovelace"
			class:invalid={form.fields.name.issues}
		/>
	</label>
	{#if form.fields.name.issues}
		{#each form.fields.name.issues as issue (issue)}
			<p class="error">{issue}</p>
		{/each}
	{/if}

	<label>
		Email
		<input
			{...contactForm.fields.email.as('email')}
			{...form.fields.email.validate}
			{...form.fields.email.persist}
			autocomplete="email"
			placeholder="[email protected]"
			class:invalid={form.fields.email.issues}
		/>
	</label>
	{#if form.fields.email.issues}
		{#each form.fields.email.issues as issue (issue)}
			<p class="error">{issue}</p>
		{/each}
	{/if}
	{#if form.fields.email.pending}
		<p class="pending">Checking...</p>
	{/if}

	<label>
		Message
		<textarea
			{...contactForm.fields.message.as('text')}
			{...form.fields.message.validate}
			{...form.fields.message.persist}
			rows="5"
			placeholder="Write at least 10 characters"
			class:invalid={form.fields.message.issues}
		></textarea>
	</label>
	{#if form.fields.message.issues}
		{#each form.fields.message.issues as issue (issue)}
			<p class="error">{issue}</p>
		{/each}
	{/if}

	<div class="actions">
		<button type="submit" class="press press-dark" disabled={form.pending}>
			{form.delayed ? 'Submitting...' : 'Send message'}
		</button>
		<button type="button" class="press press-ghost" onclick={() => form.reset()}>Reset</button>
	</div>

	<p class="status" aria-live="polite">
		State: <code>{form.state}</code>
		{#if form.timeout}
			— the request is taking longer than expected
		{/if}
	</p>
	{#if form.error}
		<p class="error">The form hit an unexpected error. Check the console for details.</p>
	{/if}
	{#if form.result}
		<p class="success">Submitted!</p>
	{/if}
</form>
form.remote.ts
import { form } from '$app/server'
import { invalid } from '@sveltejs/kit'
import { contactSchema } from './schema'

export const contactForm = form(contactSchema, async ({ name, email, message }, issue) => {
	console.log('Message from', name, email, message)

	// Simulate a slow request so the enhanced form's 'delayed' state is visible
	await new Promise((resolve) => setTimeout(resolve, 1000))

	// Server-only check the schema can't express — only runs on real submits,
	// so this issue appears via the 'issues' path, never on blur
	if (email === '[email protected]') {
		invalid(issue.email('That email is already registered'))
	}

	return {
		success: true
	}
})
schema.ts
import * as v from 'valibot'

export const contactSchema = v.object({
	name: v.pipe(v.string(), v.minLength(2, 'Name is too short')),
	email: v.pipe(v.string(), v.email('Enter a valid email')),
	message: v.pipe(v.string(), v.minLength(10, 'Message is too short'))
})