@opensky/remotes

@opensky/remotes

enhancedForm wraps a remote form function and takes care of the behaviors every real form ends up needing — one object, wired up with a few spreads.

  • Submission state — an exclusive state machine (idle, pending, issues, error, result, plus opt-in delayed/timeout) with semantic lifecycle callbacks
  • Inline validation UX — issues appear when a dirty field loses focus and clear as soon as the value is valid again, with custom sync/async validators per field
  • Draft persistence — opted-in fields save to web storage as the user types and restore after a reload
  • Auto-submit — the form submits itself once input settles, for save-button-less forms

Requires @sveltejs/kit 2.68.0 or newer (targets the current remote form enhance instance API) and svelte 5.29 or newer (uses attachments).

Installation

terminal
npm i @opensky/remotes

Remote functions are experimental, so they need to be enabled in your project config — alongside async compilation:

vite.config.ts
// vite.config.ts — remote functions and async compilation are experimental,
// and since SvelteKit 2.62 the config can live on the plugin itself
import { sveltekit } from '@sveltejs/kit/vite'
import { defineConfig } from 'vite'

export default defineConfig({
	plugins: [
		sveltekit({
			compilerOptions: { experimental: { async: true } },
			experimental: { remoteFunctions: true }
		})
	]
})

Quick start

Create the enhanced form next to your remote form, then wire both into the markup. Three kinds of spreads do all the work:

  • {...form.handlers} on the <form> element — shows preflight issues on submit attempts (even when SvelteKit blocks the submission before the enhance callback runs), clears validation state on reset, and hosts the auto-submit listener
  • {...form.fields.some.path.validate} on an input — opts the field into validation
  • {...form.fields.some.path.persist} on an input — opts the field into draft persistence
+page.svelte
<script lang="ts">
	import { enhancedForm } from '@opensky/remotes'
	import { myForm } from './myForm.remote'
	import { schema } from './schema'

	const form = enhancedForm(myForm, {
		delayMs: 500,
		timeoutMs: 3500
	})
</script>

<form
	{...form.handlers}
	{...myForm.preflight(schema).enhance((instance) =>
		form.enhance(instance, {
			onDelay: () => console.log('showing loader'),
			onReturn: ({ result }) => console.log('success', result)
		})
	)}
>
	<input
		{...myForm.fields.name.as('text')}
		{...form.fields.name.validate}
		{...form.fields.name.persist}
		class:invalid={form.fields.name.issues}
	/>

	{#if form.fields.name.issues}
		{#each form.fields.name.issues as issue}
			<p class="error">{issue}</p>
		{/each}
	{/if}

	<button disabled={form.pending}>
		{form.delayed ? 'Loading...' : 'Submit'}
	</button>
</form>

The validation field shape mirrors the remote form field shape, so TypeScript catches renamed or misspelled fields.

Options

All options are optional. delayed/timeout state and the onDelay/onTimeout callbacks only exist when delayMs/timeoutMs were passed — enforced at the type level.

options
const form = enhancedForm(myForm, {
	delayMs: 500, // unlocks the 'delayed' state and onDelay callback
	timeoutMs: 3500, // unlocks the 'timeout' state and onTimeout callback

	preventResetOnSuccess: true, // keep values after a successful submission

	persist: {
		key: 'my-form', // storage key (default: the remote form's action id)
		storage: 'session', // 'local' (default) or 'session'
		maxAgeMs: 86_400_000 // discard drafts older than this (default: no expiry)
	}
})

// autoSubmit is mutually exclusive with preventResetOnSuccess
const profileForm = enhancedForm(myForm, {
	autoSubmit: true // or { debounceMs: 600 }
})
OptionTypeDefaultDescription
delayMsnumberUnlocks the delayed state and onDelay callback
timeoutMsnumberUnlocks the timeout state and onTimeout callback
preventResetOnSuccessbooleanfalseKeep values visible after a successful submission
autoSubmitboolean | { debounceMs }falseSubmit automatically once input settles (default debounce 600ms)
persist.keystringaction idStorage key — set it to control invalidation yourself
persist.storage'local' | 'session''local'Local persists across restarts; session is per-tab and self-cleans
persist.maxAgeMsnumberno expiryDrafts older than this are discarded at restore time

After a successful submission the form element is reset, matching SvelteKit's default enhance behavior. Pass preventResetOnSuccess: true for edit/settings forms that should keep their values visible after a save. When autoSubmit is enabled the form never resets (clearing a field the user just auto-saved would be jarring), so preventResetOnSuccess is not accepted alongside it.

Validation

Fields become dirty when the user changes their value. Issues appear when a dirty field loses focus, and existing issues clear on input as soon as the value is valid again — so focus alone never shows an issue, and new issues never appear mid-keystroke. This is the same approach superforms takes, informed by the classic inline validation research.

Per field, via form.fields.some.path:

  • .validate — spread onto the input to opt it into validation
  • .issues — the field's currently displayed issues (string[] | null), handy for styling like class:invalid={form.fields.email.issues}
  • .pending — whether validation is currently running for the field
  • .addIssues(issues) — adds one or more custom validation errors, ignoring duplicate messages
  • .removeIssue(issue) — removes a custom validation error by message
  • .clearIssues() — clears issues for the field and any fields nested under it
  • .addValidator(validator) — adds a validator, returns a cleanup function
validators.ts
// A validator owns its own issue: if it returns one, it's shown;
// once it stops returning one, that issue clears itself
const removeValidator = form.fields.address.state.addValidator(({ value, issue }) => {
	if (!acceptedStates.includes(value)) {
		return issue('Your state is not accepted at this time')
	}
})

// Async validators expose `pending` on the field while they run
form.fields.email.addValidator(async ({ value, issue }) => {
	const available = await checkEmailAvailability(value)

	if (!available) {
		return issue('That email is already in use')
	}
})

Form-wide:

  • allIssues — all currently displayed issues as a nested tree (form-level issues under the '' key)
  • allKnownIssues — debugging view: every issue currently known, displayed or not
  • formIssues — issues without a field path, e.g. from invalid('message') on the server
  • clearAllIssues() — clears all validation issues and dirty tracking
  • validateAll() — validates all registered fields with the server, then updates issues

Draft persistence

Spread .persist onto a field and its value survives reloads: it saves to web storage as the user types (debounced, flushed when the value commits) and restores when the input mounts. Restored fields are marked dirty, since a restored draft is user input, not pristine state.

+page.svelte
<!-- Spreading .persist is the whole opt-in — nothing to configure -->
<textarea {...myForm.fields.message.as('text')} {...form.fields.message.persist}></textarea>

The draft is discarded on successful submission, on form reset, or when it expires. Call form.discardPersisted() to drop it programmatically (e.g. a "discard changes" button). The default storage key is the remote form's action id — stable across reloads and builds, and it self-invalidates when the remote function moves or is renamed (.for(key) instances get distinct keys automatically). File inputs cannot be persisted and are skipped.

Auto-submit

For forms that shouldn't need a save button — a profile field that saves once you stop typing:

auto-submit
const form = enhancedForm(myForm, {
	autoSubmit: true // or { autoSubmit: { debounceMs: 600 } }
})

Once input settles for debounceMs (default 600ms), the form submits itself via requestSubmit() — a real submit event, so preflight validation, the submit-attempt issue display, and your enhance callbacks all run exactly as they would for a button press. Behaviors that are built in rather than configurable:

  • Dirty check — data identical to the last submission is never re-submitted, so a settled debounce after a save is a no-op
  • In-flight coalescing — a debounce that fires mid-submission waits for it to settle, then submits once more only if the data changed since
  • Commit flush — a change event (text field blur, select/checkbox pick) submits immediately instead of waiting out the debounce

Auto-submitting forms never reset after success, and a debounce still pending when the form unmounts is dropped — pair with .persist if that draft matters.

State & callbacks

The form is always in exactly one state, and the state type is derived from your options — delayed and timeout only exist when you asked for them.

  • state — current form state (exclusive; type-safe based on creation options)
  • idle, pending, issues, error, result — boolean getters, always available. pending stays true through delayed and timeout, so disabled={form.pending} is sufficient on its own
  • delayed — only when delayMs was provided; stays true through timeout
  • timeout — only when timeoutMs was provided
  • reset() — resets the form element and the submission state (also clears validation state and discards the persisted draft)
  • resetState() — resets only the submission state back to idle, leaving values alone

Callbacks (all optional):

  • onSubmit — submission begins; also receives cancel(state?) and updates(...queries) for optimistic updates
  • onDelay — delayed state reached (requires delayMs)
  • onTimeout — timeout state reached (requires timeoutMs)
  • onReturn — submission returned successfully; receives result
  • onIssues — submission returned validation issues
  • onError — submission hit an error; receives error

Every callback receives the remote form instance as form — the same object SvelteKit passes to the enhance callback. If you named your enhanced form form, destructuring the instance as form inside a callback shadows it — destructure only what you need instead.

+page.svelte
<form
	{...form.handlers}
	{...myForm.preflight(schema).enhance((instance) =>
		form.enhance(instance, {
			onSubmit: ({ cancel, updates }) => {
				// Custom client-side checks before submission
				if (!customValidationCheck(myForm.fields.value())) {
					form.fields.fieldName.addIssues('Custom validation failed')
					cancel('issues') // cancel and set state to 'issues'
					return
				}

				// Optimistic updates
				updates(getPosts().withOverride((posts) => [newPost, ...posts]))
			},
			onReturn: ({ result }) => {},
			onIssues: () => {},
			onError: ({ error }) => {}
		})
	)}
>
	<!-- form fields -->
</form>

Notes

Remote functions and their validation story are still moving quickly. This library uses the newly added preflightOnly flag so validation never hits the server on every keystroke, but there are known rough edges in the current Kit implementation:

  • Server-side issues don't come back until the form is submitted — they should also arrive on blur
  • When the server returns validation issues, mutating any field currently clears all of them — only the mutated field's issues should clear