DEV Community

Matt DeKok
Matt DeKok

Posted on • Edited on

Sveltekit Custom Remote Form Factory

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

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
    });
}
Enter fullscreen mode Exit fullscreen mode

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
    });
 }
Enter fullscreen mode Exit fullscreen mode

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
    });
 }
Enter fullscreen mode Exit fullscreen mode

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
    });
 }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Key features:

  1. SSR Support: The ssr callback runs immediately during server-side rendering, allowing you to set initial values that will be present in the HTML sent to the client.

  2. Mount Handling: The mount callback 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.

  3. Reactive Effects: The effect callback 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.

  4. Snapshot Return: The function returns a snapshot of the tracked value, which is useful for creating immutable references for comparison (like our initial value for dirty state tracking).

  5. Cleanup Support: The effect callback 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 ssr callback sets form fields during SSR so the form appears pre-filled in the initial HTML
  • The effect callback updates form fields reactively when the data prop 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
    });
 }
Enter fullscreen mode Exit fullscreen mode

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
    });
 }
Enter fullscreen mode Exit fullscreen mode

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
    });
 }
Enter fullscreen mode Exit fullscreen mode

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
    });
 }
Enter fullscreen mode Exit fullscreen mode

The submission handler:

  • Calls onsubmit before submission (can cancel by returning false)
  • Tracks submitting and submitted states
  • Handles success/error cases
  • Calls onresult with 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 ...
    });
 }
Enter fullscreen mode Exit fullscreen mode

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)
    });
 }
Enter fullscreen mode Exit fullscreen mode

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:

  1. Form instance management - Proper scoping with keys
  2. Schema validation - Preflight validation before submission
  3. Data initialization - Populate forms with existing data using use helper
  4. Dirty state tracking - Know when forms have been modified
  5. Touched state tracking - Know when user has interacted with the form
  6. Reactive updates - Respond to form instance changes using use
  7. Validation - Debounced validation with issue tracking
  8. Submission handling - Comprehensive success/error handling
  9. Navigation blocking - Prevent accidental data loss
  10. Focus management - Auto-focus invalid fields
  11. Callbacks - Flexible hooks for customization
  12. 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)
    });
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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)