@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-indelayed/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
npm i @opensky/remotesRemote functions are experimental, so they need to be enabled in your project config — alongside async compilation:
// 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
<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.
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 }
})| Option | Type | Default | Description |
|---|---|---|---|
| delayMs | number | — | Unlocks the delayed state and onDelay callback |
| timeoutMs | number | — | Unlocks the timeout state and onTimeout callback |
| preventResetOnSuccess | boolean | false | Keep values visible after a successful submission |
| autoSubmit | boolean | { debounceMs } | false | Submit automatically once input settles (default debounce 600ms) |
| persist.key | string | action id | Storage key — set it to control invalidation yourself |
| persist.storage | 'local' | 'session' | 'local' | Local persists across restarts; session is per-tab and self-cleans |
| persist.maxAgeMs | number | no expiry | Drafts 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 likeclass: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
// 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 notformIssues— issues without a field path, e.g. frominvalid('message')on the serverclearAllIssues()— clears all validation issues and dirty trackingvalidateAll()— 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.
<!-- 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:
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
changeevent (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.pendingstays true throughdelayedandtimeout, sodisabled={form.pending}is sufficient on its owndelayed— only whendelayMswas provided; stays true through timeouttimeout— only whentimeoutMswas providedreset()— 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 receivescancel(state?)andupdates(...queries)for optimistic updatesonDelay— delayed state reached (requiresdelayMs)onTimeout— timeout state reached (requirestimeoutMs)onReturn— submission returned successfully; receivesresultonIssues— submission returned validation issuesonError— submission hit an error; receiveserror
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.
<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