Introduction
In this post, we'll explore how to build a comprehensive form configuration factory function for SvelteKit's Remote Forms. We'll start with a basic implementation and progressively add features, building up to a production-ready solution that handles validation, dirty state tracking, navigation blocking, and more.
Table of Contents
- Prerequisites
- Basic Function Signature and Types
- Form Instance Management
- Schema Validation
- Initializing Form Data
- Reactive Instance Updates
- Tracking Dirty State
- Adding Validation
- Enhanced Submission Handling
- Navigation Blocking
- Focus Management
- Complete Implementation
- Usage Example
Prerequisites
This post assumes familiarity with:
- Svelte 5 runes (
$state,$derived,$effect) - SvelteKit Remote Forms
- TypeScript generics
Basic Function Signature and Types
Let's start by defining the core types and function signature. We need to work with SvelteKit's RemoteForm type and create a flexible options interface.
import type { RemoteForm, RemoteFormInput } from "@sveltejs/kit";
import type { HTMLFormAttributes } from "svelte/elements";
export interface RemoteFormOptions<Input extends RemoteFormInput> {
form: RemoteForm<Input, unknown>;
// Options will be added incrementally
}
export function configureForm<Input extends RemoteFormInput>(getProps: () => RemoteFormOptions<Input>) {
const { form } = $derived(getProps());
const attributes = $derived(
form.enhance(async ({ submit }) => {
await submit();
})
);
return () => ({
form,
attributes
});
}
At this stage, we have a minimal wrapper that takes a function returning RemoteFormOptions (which includes the RemoteForm instance) and returns a configured form with basic enhancement. The enhance method returns form attachments (Svelte action functions), method, and action properties. The function accepts a reactive options getter (using a function allows for reactive access to options) and returns a function that provides form state and attributes. The options interface extends HTMLFormAttributes, allowing you to pass standard HTML form attributes directly.
Form Instance Management
SvelteKit's Remote Forms use a .for() method to create form instances scoped to a specific key. This allows multiple forms on the same page without conflicts. Let's add support for this:
+import { v7 } from "uuid";
import type { RemoteForm, RemoteFormInput } from "@sveltejs/kit";
+import type { HTMLFormAttributes } from "svelte/elements";
+export type FormId<Input> = Input extends { id: infer Id } ? (Id extends string | number ? Id : string | number) : string | number;
-export interface RemoteFormOptions<Input extends RemoteFormInput> {
+export interface RemoteFormOptions<Input extends RemoteFormInput | undefined = undefined> {
form: RemoteForm<Input, unknown>;
- // Options will be added incrementally
+ key?: FormId<Input>;
+ data?: Input;
}
-export function configureForm<Input extends RemoteFormInput>(
+export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
- const { form: remoteForm } = $derived(getProps());
- const form = $derived(remoteForm.for(v7()));
+ const {
+ form: remoteForm,
+ data: formData,
+ key: formKey
+ } = $derived(getProps());
+
+ type FormData = Input extends undefined ? Record<string, never> : Input;
+ const data = $derived((formData ?? {}) as FormData);
+ const key = $derived(formKey ?? ((data.id ?? v7()) as FormId<Input>));
+ const form = $derived(remoteForm.for(key));
const attributes = $derived(
form.enhance(async ({ submit }) => {
await submit();
})
);
return () => ({
form,
attributes
});
}
Now the form is properly scoped using either an explicit key, the data's ID, or a generated UUID. The key is derived reactively, ensuring the form instance updates when the key or data changes.
Schema Validation
Preflight validation with a schema allows us to catch errors before submission. Let's add schema support:
+import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { RemoteForm, RemoteFormInput } from "@sveltejs/kit";
+import type { HTMLFormAttributes } from "svelte/elements";
import { v7 } from "uuid";
export interface RemoteFormOptions<Input extends RemoteFormInput | undefined = undefined> {
form: RemoteForm<Input, unknown>;
key?: FormId<Input>;
+ schema?: StandardSchemaV1<Input, unknown>;
data?: Input;
}
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
const {
form: remoteForm,
data: formData,
key: formKey,
+ schema
} = $derived(getProps());
type FormData = Input extends undefined ? Record<string, never> : Input;
const data = $derived((formData ?? {}) as FormData);
const key = $derived(formKey ?? ((data?.id ?? v7()) as FormId<Input>));
- const form = $derived(remoteForm.for(key));
+ const form = $derived(schema ? remoteForm.for(key).preflight(schema) : remoteForm.for(key));
const attributes = $derived(
form.enhance(async ({ submit }) => {
await submit();
})
);
return () => ({
form,
attributes
});
}
The .preflight() method adds client-side validation that runs before the form is submitted to the server.
Initializing Form Data
When editing existing data, we need to populate the form fields. Let's add data initialization using a use helper function:
+import type { RemoteFormFields } from "@sveltejs/kit";
+import { deepEqual } from "@sillvva/utils";
+import { untrack } from "svelte";
// ... existing imports ...
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
+ type Fields = RemoteFormFields<unknown>;
+
const {
form: remoteForm,
data: formData,
key: formKey,
schema
} = $derived(getProps());
type FormData = Input extends undefined ? Record<string, never> : Input;
const data = $derived((formData ?? {}) as FormData);
const key = $derived(formKey ?? ((data?.id ?? v7()) as FormId<Input>));
const form = $derived(schema ? remoteForm.for(key).preflight(schema) : remoteForm.for(key));
+ let initial = $state.raw(
+ use({
+ track: () => data,
+ // set the initial data during SSR
+ ssr: (data) => {
+ form.fields.set(data as any);
+ },
+ // update the form fields when the data changes
+ effect: (data) => {
+ form.fields.set(data as any);
+ }
+ })
+ );
const attributes = $derived(
form.enhance(async ({ submit }) => {
await submit();
})
);
return () => ({
form,
attributes
});
}
We use a use helper function that handles SSR, hydration, and reactive updates. The use function sets the initial data snapshot during SSR and updates form fields reactively when data changes. This snapshot will be used later for dirty state tracking.
The use Helper Function
The use helper is a utility function that provides a unified way to handle reactive updates across SSR, hydration, and client-side rendering. It's particularly useful for form initialization because it ensures data is set correctly during SSR (for SEO and initial render) and then reactively updates when data changes.
Here's how use works:
export function use<T>(
args:
| (() => T)
| {
/** Depedencies to track */
track: () => T;
/** Effects that run once each during SSR and hydration */
ssr?: (value: T) => unknown;
/** Effects that run once during mount */
mount?: (value: T) => unknown;
/** Effects that run on dependency change, before the DOM updates */
pre?: (current: T, previous: T) => void | (() => void);
/** Effects that run on dependency change, after the DOM updates */
effect?: (current: T, previous: T) => void | (() => void);
}
) {
if (typeof args === "function") return $state.snapshot(args());
const initial = args.track();
let mounted = false;
let pre_v = initial;
let prev = initial;
args.ssr?.(initial);
if (args.pre) {
$effect.pre(() => {
const current = args.track();
void $state.snapshot(current);
return untrack(() => {
if (!mounted) return;
const cleanup = args.pre?.(current, pre_v);
pre_v = current;
return cleanup;
});
});
}
$effect(() => {
const current = args.effect ? args.track() : untrack(() => args.track());
if (args.effect) void $state.snapshot(current);
return untrack(() => {
if (!mounted) {
if (args.mount) args.mount(current);
return void (mounted = true);
}
const cleanup = args.effect?.(current, prev);
prev = current;
return cleanup;
});
});
return $state.snapshot(initial);
}
Key features:
SSR Support: The
ssrcallback runs immediately during server-side rendering, allowing you to set initial values that will be present in the HTML sent to the client.Mount Handling: The
mountcallback runs once when the component mounts on the client after hydration, but before any reactive effects run. This is useful for one-time setup that shouldn't trigger reactive updates.Reactive Effects: The
effectcallback runs whenever the tracked value changes, but only after the component mounts. It receives both the current and previous values, allowing you to compare changes.Snapshot Return: The function returns a snapshot of the tracked value, which is useful for creating immutable references for comparison (like our
initialvalue for dirty state tracking).Cleanup Support: The
effectcallback can return a cleanup function that will be called when the effect runs again or when the component is destroyed.
In our form initialization use case:
- The
ssrcallback sets form fields during SSR so the form appears pre-filled in the initial HTML - The
effectcallback updates form fields reactively when thedataprop changes - The returned snapshot (
initial) is used later to compare against current form values for dirty state tracking
Reactive Instance Updates
Forms often need to react to changes in data or form instance. Let's add reactive updates using the use helper:
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
type Fields = RemoteFormFields<unknown>;
const {
form: remoteForm,
data: formData,
key: formKey,
schema
} = $derived(getProps());
type FormData = Input extends undefined ? Record<string, never> : Input;
const data = $derived((formData ?? {}) as FormData);
const key = $derived(formKey ?? ((data?.id ?? v7()) as FormId<Input>));
const form = $derived(schema ? remoteForm.for(key).preflight(schema) : remoteForm.for(key));
// ... existing code ...
+ use({
+ // if the form instance changes, reset the form
+ track: () => form,
+ effect: (form) => {
+ form.fields.set(data as any);
+ initial = $state.snapshot(data);
+ }
+ });
return () => ({
form,
attributes
});
}
The use function monitors the form instance and resets the form when it changes. The effect callback updates form fields and resets the initial snapshot when the form instance changes.
Tracking Touched and Dirty States
Dirty state tells us if the form has been modified. This is crucial for preventing accidental data loss:
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
// ... existing code ...
let initial = $state.raw(
use({
track: () => data,
ssr: (data) => {
form.fields.set(data as any);
},
effect: (data) => {
form.fields.set(data as any);
}
})
);
+ let touched = $state.raw(false);
+ let dirty = $derived(!deepEqual(initial, $state.snapshot(form.fields.value())));
// ... existing code ...
use({
// if the form instance changes, reset the form
track: () => form,
effect: (form) => {
form.fields.set(data as any);
initial = $state.snapshot(data);
+ touched = false;
}
});
+ onMount(() => {
+ const handleFocusIn = () => void (touched = true);
+ formEl?.addEventListener("focusin", handleFocusIn);
+ return () => {
+ formEl?.removeEventListener("focusin", handleFocusIn);
+ };
+ });
return () => ({
form,
attributes,
+ initial,
+ touched,
+ dirty
});
}
The dirty derived state compares the current form values against the initial snapshot using deep equality checking. We also track touched state separately to know if the user has interacted with the form.
Adding Validation
Let's add validation with debouncing and issue tracking:
+import { debounce } from "@sillvva/utils";
+import type { RemoteFormIssue } from "@sveltejs/kit";
+import { onMount } from "svelte";
// ... existing imports ...
export interface RemoteFormOptions<Input extends RemoteFormInput | undefined = undefined> {
form: RemoteForm<Input, unknown>;
key?: FormId<Input>;
schema?: StandardSchemaV1<Input, unknown>;
data?: Input;
+ initialErrors?: boolean;
+ onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => unknown;
+ oninput?: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => void;
}
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
type Fields = RemoteFormFields<unknown>;
const {
form: remoteForm,
data: formData,
key: formKey,
schema,
+ initialErrors: initialErrorsProp,
+ onissues,
+ oninput,
+ formEl
} = $derived(getProps());
type FormData = Input extends undefined ? Record<string, never> : Input;
const data = $derived((formData ?? {}) as FormData);
const key = $derived(formKey ?? ((data?.id ?? v7()) as FormId<Input>));
const form = $derived(schema ? remoteForm.for(key).preflight(schema) : remoteForm.for(key));
// ... existing code ...
const attributes = $derived(
Object.assign(
form.enhance(async ({ submit }) => {
await submit();
}),
+ {
+ oninput: () => {
+ if (lastIssues) debouncedValidate.call();
+ }
+ }
)
);
+ const issues = $derived(form.fields.issues());
+ const allIssues = $derived((form.fields as Fields).allIssues());
+ const initialErrors = $derived(initialErrorsProp ?? !!data?.id);
+ let lastIssues = $state.raw<RemoteFormIssue[] | undefined>();
+ const debouncedValidate = debounce(validate, 300);
+
+ async function validate(reset = false) {
+ await form.validate({ includeUntouched: true, preflightOnly: true });
+ if (allIssues && onissues && !deepEqual(lastIssues, allIssues)) onissues({ issues: allIssues });
+ if (allIssues) lastIssues = allIssues;
+ else if (reset) lastIssues = undefined;
+ if (
+ formEl?.querySelector(":is(input, select, textarea):disabled") &&
+ allIssues?.some((issue) => issue.message.includes("undefined"))
+ ) {
+ console.warn(
+ "Ensure that form fields are not disabled when the validation is run.",
+ "Disabled fields are treated as undefined values, just as they would be if the form was submitted."
+ );
+ }
+ }
use({
// if the form instance changes, reset the form
track: () => form,
+ hydration: async () => {
+ if (initialErrors) await validate();
},
effect: (form) => {
form.fields.set(data as any);
initial = $state.snapshot(data);
touched = false;
+ if (initialErrors) validate(true);
}
});
return () => ({
form,
attributes,
initial,
touched,
dirty,
+ issues,
+ allIssues,
+ validate,
+ debouncedValidate
});
}
Validation runs on mount if initialErrors is true (handled in the use hydration callback), and debounced validation triggers on input when there are existing issues. The onissues callback notifies consumers of validation issues. The validate function is exposed for manual validation calls.
Enhanced Submission Handling
Now let's add comprehensive submission handling with success/error states and callbacks:
export interface RemoteFormOptions<Input extends RemoteFormInput | undefined = undefined> {
form: RemoteForm<Input, unknown>;
key?: FormId<Input>;
schema?: StandardSchemaV1<Input, unknown>;
data?: Input;
initialErrors?: boolean;
+ onsubmit?: <T>(ctx: { readonly dirty: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>;
+ onresult?: (ctx: {
+ readonly success: boolean;
+ readonly result?: RemoteForm<Input, unknown>["result"];
+ readonly issues?: RemoteFormIssue[];
+ readonly error?: string;
+ }) => Awaitable<void>;
onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => unknown;
oninput?: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => void;
+ formEl?: HTMLFormElement;
}
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
// ... existing code ...
let touched = $state.raw(false);
+ let submitting = $state.raw(false);
+ let submitted = $state.raw(false);
let dirty = $derived(!deepEqual(initial, $state.snapshot(form.fields.value())));
const attributes = $derived(
Object.assign(
- form.enhance(async ({ submit }) => {
+ form.enhance(async ({ submit, form: formEl, data }) => {
+ const { onsubmit, onresult, onissues } = getProps();
+ const bf = !onsubmit || (await onsubmit({ dirty, form: formEl, data }));
+ if (!bf) return;
+
+ submitting = true;
+ submitted = true;
+ const wasDirty = dirty;
+ try {
+ dirty = false;
+ await submit();
+
+ const success = !allIssues;
+ onresult?.({ success, result: form.result, issues: allIssues });
+
+ if (!success) {
+ dirty = wasDirty;
+ await focusInvalid();
+ onissues?.({ issues: allIssues });
+ }
+ } catch (error) {
+ onresult?.({ success: false, error: unknownErrorMessage(error) });
+ dirty = wasDirty;
+ } finally {
+ submitting = false;
+ }
+ }),
+ {
+ oninput: () => {
+ if (lastIssues) debouncedValidate.call();
+ }
+ }
+ )
+ );
+ const result = $derived(form.result);
const issues = $derived(form.fields.issues());
const allIssues = $derived((form.fields as Fields).allIssues());
const initialErrors = $derived(initialErrorsProp ?? !!data?.id);
let lastIssues = $state.raw<RemoteFormIssue[] | undefined>();
return () => ({
form,
attributes,
initial,
touched,
dirty,
+ submitting,
+ submitted,
+ result,
issues,
allIssues,
validate,
debouncedValidate
});
}
The submission handler:
- Calls
onsubmitbefore submission (can cancel by returning false) - Tracks submitting and submitted states
- Handles success/error cases
- Calls
onresultwith the outcome - Restores dirty state if submission fails
- Focuses invalid fields on error
Navigation Blocking
Prevent accidental navigation when there are unsaved changes:
+import { beforeNavigate } from "$app/navigation";
// ... existing imports ...
export interface RemoteFormOptions<Input extends RemoteFormInput | undefined = undefined> {
form: RemoteForm<Input, unknown>;
key?: FormId<Input>;
schema?: StandardSchemaV1<Input, unknown>;
data?: Input;
initialErrors?: boolean;
+ navBlockMessage?: string;
onsubmit?: <T>(ctx: { readonly dirty: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>;
onresult?: (ctx: {
readonly success: boolean;
readonly result?: RemoteForm<Input, unknown>["result"];
readonly issues?: RemoteFormIssue[];
readonly error?: string;
}) => Awaitable<void>;
onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => unknown;
oninput?: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => void;
formEl?: HTMLFormElement;
}
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
// ... existing code ...
+ beforeNavigate((ev) => {
+ const { navBlockMessage } = getProps();
+ if ((dirty || issues) && navBlockMessage && !confirm(navBlockMessage)) ev.cancel();
+ });
return () => ({
// ... previous return ...
});
}
The beforeNavigate hook checks if the form is dirty or has issues, and prompts the user before allowing navigation.
Focus Management
Finally, let's add automatic focus to invalid fields:
+import { tick } from "svelte";
// ... existing imports ...
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
// ... existing code ...
const attributes = $derived(
Object.assign(
form.enhance(async ({ submit, form: formEl, data }) => {
// ... existing submission handler ...
}),
{
+ onsubmit: focusInvalid,
oninput: () => {
if (lastIssues) debouncedValidate.call();
}
}
)
);
+ async function focusInvalid() {
+ await tick();
+
+ if (allIssues) lastIssues = allIssues;
+ else return;
+
+ const invalid = formEl?.querySelector(":is(input, select, textarea):not(.hidden, [type=hidden], :disabled)[aria-invalid]") as
+ | HTMLInputElement
+ | HTMLSelectElement
+ | HTMLTextAreaElement
+ | null
+ | undefined;
+ invalid?.focus();
+ }
use({
// if the form instance changes, reset the form
track: () => form,
hydration: async () => {
- if (initialErrors) validate(true);
+ if (initialErrors) validate(true).then(focusInvalid);
// ... existing code ...
},
effect: (form) => {
form.fields.set(data as any);
initial = $state.snapshot(data);
touched = false;
- if (initialErrors) validate(true);
+ if (initialErrors) validate(true).then(focusInvalid);
}
});
return () => ({
form,
attributes,
initial,
touched,
dirty,
submitting,
submitted,
result,
issues,
allIssues,
validate,
debouncedValidate,
+ reset: () => form.fields.set(initial as any)
});
}
The focusInvalid function finds the first invalid field and focuses it, improving accessibility. The onMount hook tracks when the form is touched (user has focused into any field). The reset function is exposed to allow programmatic form reset.
Complete Implementation
The final configureForm function provides:
- Form instance management - Proper scoping with keys
- Schema validation - Preflight validation before submission
-
Data initialization - Populate forms with existing data using
usehelper - Dirty state tracking - Know when forms have been modified
- Touched state tracking - Know when user has interacted with the form
-
Reactive updates - Respond to form instance changes using
use - Validation - Debounced validation with issue tracking
- Submission handling - Comprehensive success/error handling
- Navigation blocking - Prevent accidental data loss
- Focus management - Auto-focus invalid fields
- Callbacks - Flexible hooks for customization
- Reset function - Programmatic form reset
Here's the complete implementation:
import { beforeNavigate } from "$app/navigation";
import { debounce, deepEqual } from "@sillvva/utils";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import type { RemoteForm, RemoteFormFields, RemoteFormInput, RemoteFormIssue } from "@sveltejs/kit";
import { onMount, tick, untrack } from "svelte";
import type { HTMLFormAttributes } from "svelte/elements";
import { v7 } from "uuid";
export function use<T>(
args:
| (() => T)
| {
/** Depedencies to track */
track: () => T;
/** Effects that run once each during SSR and hydration */
ssr?: (value: T) => unknown;
/** Effects that run once during mount */
mount?: (value: T) => unknown;
/** Effects that run on dependency change, before the DOM updates */
pre?: (current: T, previous: T) => void | (() => void);
/** Effects that run on dependency change, after the DOM updates */
effect?: (current: T, previous: T) => void | (() => void);
}
) {
if (typeof args === "function") return $state.snapshot(args());
const initial = args.track();
let mounted = false;
let pre_v = initial;
let prev = initial;
args.ssr?.(initial);
if (args.pre) {
$effect.pre(() => {
const current = args.track();
void $state.snapshot(current);
return untrack(() => {
if (!mounted) return;
const cleanup = args.pre?.(current, pre_v);
pre_v = current;
return cleanup;
});
});
}
$effect(() => {
const current = args.effect ? args.track() : untrack(() => args.track());
if (args.effect) void $state.snapshot(current);
return untrack(() => {
if (!mounted) {
if (args.mount) args.mount(current);
return void (mounted = true);
}
const cleanup = args.effect?.(current, prev);
prev = current;
return cleanup;
});
});
return $state.snapshot(initial);
}
export type GenericFormConfig<T extends RemoteFormInput | undefined = RemoteFormInput> = ReturnType<typeof configureForm<T>>;
export type GenericForm<T extends RemoteFormInput | undefined = RemoteFormInput> = ReturnType<GenericFormConfig<T>>;
type FormId<Input> = Input extends { id: infer Id } ? (Id extends string | number ? Id : string | number) : string | number;
export interface RemoteFormOptions<Input extends RemoteFormInput | undefined = undefined> {
form: RemoteForm<Input, unknown>;
schema?: StandardSchemaV1<Input, unknown>;
key?: FormId<Input>;
data?: Input;
initialErrors?: boolean;
navBlockMessage?: string;
onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => unknown;
onsubmit?: <T>(ctx: { readonly dirty: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>;
onresult?: (ctx: {
readonly success: boolean;
readonly result?: RemoteForm<Input, unknown>["result"];
readonly issues?: RemoteFormIssue[];
readonly error?: string;
}) => Awaitable<void>;
formEl?: HTMLFormElement;
}
export function configureForm<Input extends RemoteFormInput | undefined = undefined>(getProps: () => RemoteFormOptions<Input>) {
type Fields = RemoteFormFields<unknown>;
const {
form: remoteForm,
schema,
data: formData,
key: formKey,
initialErrors: initialErrorsProp,
navBlockMessage,
onsubmit,
onresult,
onissues,
formEl
} = $derived(getProps());
type FormData = Input extends undefined ? Record<string, never> : Input;
const data = $derived((formData ?? {}) as FormData);
const key = $derived(formKey ?? ((data.id ?? v7()) as FormId<Input>));
const form = $derived(schema ? remoteForm.for(key).preflight(schema) : remoteForm.for(key));
let initial = $state.raw(
use({
track: () => data,
ssr: (data) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.fields.set(data as any);
},
effect: (data) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.fields.set(data as any);
}
})
);
let touched = $state.raw(false);
let submitting = $state.raw(false);
let submitted = $state.raw(false);
let dirty = $derived(!deepEqual(initial, $state.snapshot(form.fields.value())));
const attributes = $derived(
Object.assign(
form.enhance(async ({ submit, form: formEl, data }) => {
if (submitting) return;
const bf = !onsubmit || (await onsubmit({ dirty, form: formEl, data }));
if (!bf) return;
submitting = true;
submitted = true;
const wasDirty = dirty;
try {
dirty = false;
await submit();
const success = !allIssues;
onresult?.({ success, result: form.result, issues: allIssues });
if (!success) {
dirty = wasDirty;
await focusInvalid();
onissues?.({ issues: allIssues });
}
} catch (error) {
onresult?.({ success: false, error: unknownErrorMessage(error) });
dirty = wasDirty;
} finally {
submitting = false;
}
}),
{
onsubmit: focusInvalid,
oninput: () => {
if (lastIssues) debouncedValidate.call();
}
}
)
);
const result = $derived(form.result);
const issues = $derived(form.fields.issues());
const allIssues = $derived((form.fields as Fields).allIssues());
const initialErrors = $derived(initialErrorsProp ?? !!data?.id);
let lastIssues = $state.raw<RemoteFormIssue[] | undefined>();
use({
track: () => form,
hydration: async () => {
if (initialErrors) await validate().then(focusInvalid);
},
effect: (form) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
form.fields.set(data as any);
initial = $state.snapshot(data);
touched = false;
if (initialErrors) validate(true).then(focusInvalid);
}
});
const debouncedValidate = debounce(validate, 300);
async function validate(reset = false) {
await form.validate({ includeUntouched: true, preflightOnly: true });
if (allIssues && onissues && !deepEqual(lastIssues, allIssues)) onissues({ issues: allIssues });
if (reset) lastIssues = undefined;
if (allIssues) lastIssues = allIssues;
if (formEl?.querySelector(":is(input, select, textarea):disabled") && allIssues?.some((issue) => issue.message.includes("undefined"))) {
console.warn(
"Ensure your form fields are not disabled when the validation is run.",
"Disabled fields are treated as undefined values, just as they would be if the form was submitted."
);
}
}
async function focusInvalid() {
await tick();
if (allIssues) lastIssues = allIssues;
else return;
const invalid = formEl?.querySelector(":is(input, select, textarea):not(.hidden, [type=hidden], :disabled)[aria-invalid]") as
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement
| null
| undefined;
invalid?.focus();
}
onMount(() => {
const handleFocusIn = () => void (touched = true);
formEl?.addEventListener("focusin", handleFocusIn);
return () => {
formEl?.removeEventListener("focusin", handleFocusIn);
};
});
beforeNavigate((ev) => {
if ((dirty || issues) && navBlockMessage && !confirm(navBlockMessage)) ev.cancel();
});
return () => ({
form,
attributes,
initial,
touched,
dirty,
submitting,
submitted,
result,
issues,
allIssues,
validate,
debouncedValidate,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
reset: () => form.fields.set(initial as any)
});
}
Usage Example
<script>
import { configureForm } from "$lib/factories.svelte";
import { remoteForm } from "$lib/remote";
let formEl: HTMLFormElement | undefined = $state.raw();
const configured = configureForm(() => ({
form: remoteForm,
formEl,
schema: mySchema,
data: existingData,
initialErrors: true,
navBlockMessage: "You have unsaved changes. Are you sure?",
onresult: ({ success, error }) => {
if (success) {
toast.success("Form saved successfully");
} else if (error) {
toast.error(error);
}
}
}));
const { form, attributes, dirty, submitting, touched, reset } = $derived(configured());
</script>
<form bind:this={formEl} {...attributes}>
<!-- form fields -->
</form>
{#if dirty}
<p>You have unsaved changes. <button onclick={reset}>Reset</button></p>
{/if}
This factory function provides a robust foundation for building forms in SvelteKit applications, handling the common concerns that arise in form development.
Top comments (1)