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.

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";

export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    // Options will be added incrementally
}

export interface ConfiguredForm<Input extends RemoteFormInput, Output> {
    (): {
        form: Omit<RemoteForm<Input, Output>, "for">;
        attrs: {
            [attachment: symbol]: (node: HTMLFormElement) => void;
            method: "POST";
            action: string;
        };
    };
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    return () => ({
        form: form,
        attrs: {
            ...form.enhance(async ({ submit }) => {
                await submit();
            })
        }
    });
}

At this stage, we have a minimal wrapper that takes a RemoteForm and returns a configured form with basic enhancement. The enhance method returns form attachments (Svelte action functions), method, and action properties. The function accepts an options callback (using a function allows for reactive access to options) and returns a function that provides form state and attributes.

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 type { RemoteForm, RemoteFormInput } from "@sveltejs/kit";
import { v7 } from "uuid";

export type ExtractId<Input> = Input extends { id: infer Id } ? (Id extends string | number ? Id : string | number) : string | number;

export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    key?: ExtractId<Input>;
    data?: Input | $state.Snapshot<Input>;
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form);

    function setup(config: RemoteFormOptions<Input, Output>) {
        const formKey = config.key ?? config.data?.id ?? v7();
        configuredForm = form.for(formKey as ExtractId<Input>);
    }

    setup(options());

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit }) => {
                await submit();
            })
        }
    });
}

Now the form is properly scoped using either an explicit key, the data’s ID, or a generated UUID. The setup function handles the configuration logic, which we’ll expand as we add more features.

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 { v7 } from "uuid";

export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    key?: ExtractId<Input>;
    schema?: StandardSchemaV1<Input, unknown>;
    data?: Input | $state.Snapshot<Input>;
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form);

    function setup(config: RemoteFormOptions<Input, Output>) {
        const formKey = config.key ?? config.data?.id ?? v7();
        configuredForm = form.for(formKey as ExtractId<Input>);

        if (config?.schema) {
            configuredForm = configuredForm.preflight(config.schema as unknown as StandardSchemaV1<Input, unknown>);
        }
    }

    setup(options());

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit }) => {
                await submit();
            })
        }
    });
}

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:

import type { RemoteFormFields } from "@sveltejs/kit";
// ... existing imports ...

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form);
    let initial = $state.raw($state.snapshot(options().data));

    type Fields = RemoteFormFields<unknown>;

    function setup(config: RemoteFormOptions<Input, Output>) {
        initial = $state.snapshot(config?.data);

        const formKey = config.key ?? config.data?.id ?? v7();
        configuredForm = form.for(formKey as ExtractId<Input>);

        if (config?.schema) {
            configuredForm = configuredForm.preflight(config.schema as unknown as StandardSchemaV1<Input, unknown>);
        }

        if (config.data) {
            (configuredForm.fields as Fields).set(config.data);
        }
    }

    setup(options());

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit }) => {
                await submit();
            })
        }
    });
}

We store the initial data snapshot to compare against later (for dirty state tracking), and populate the form fields if data is provided.

Reactive Configuration Updates

Forms often need to react to changes in data or URL. Let’s add reactive updates:

import { untrack } from "svelte";
import { page } from "$app/state";
// ... existing imports ...

export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    key?: ExtractId<Input>;
    schema?: StandardSchemaV1<Input, unknown>;
    data?: Input | $state.Snapshot<Input>;
    resetOn?: "data" | "url";
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form);
    let initial = $state.raw($state.snapshot(options().data));

    type Fields = RemoteFormFields<unknown>;

    function setup(config: RemoteFormOptions<Input, Output>) {
        initial = $state.snapshot(config?.data);

        const formKey = config.key ?? config.data?.id ?? v7();
        configuredForm = form.for(formKey as ExtractId<Input>);

        if (config?.schema) {
            configuredForm = configuredForm.preflight(config.schema as unknown as StandardSchemaV1<Input, unknown>);
        }

        if (config.data) {
            (configuredForm.fields as Fields).set(config.data);
        }
    }

    setup(options());
    let hydrated = false;

    $effect(() => {
        const config = untrack(options);
        // [!code ++]
        const updateOn = config.resetOn ?? (untrack(() => config.data) ? "data" : "url");
        if (updateOn === "data") void options().data;
        else void page.url;
        // [!code ++]
        if (!hydrated) return void (hydrated = true);
        untrack(() => setup(config));
    });

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit }) => {
                await submit();
            })
        }
    });
}

The $effect watches for changes in either the data or URL (depending on resetOn), and reconfigures the form when changes occur. The hydrated flag prevents the effect from running on initial mount.

Tracking Dirty State

Dirty state tells us if the form has been modified. This is crucial for preventing accidental data loss:

import { deepEqual } from "@sillvva/utils";
// ... existing imports ...

export interface ConfiguredForm<Input extends RemoteFormInput, Output> {
    (): {
        form: Omit<RemoteForm<Input, Output>, "for">;
        attrs: {
            method: "POST";
            action: string;
            onsubmit: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => Promise<void>;
        };
        dirty: boolean;
    };
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    // ... existing setup code ...

    let dirty = $derived(!deepEqual(initial, $state.snapshot(configuredForm.fields.value())));

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit }) => {
                await submit();
            })
        },
        dirty
    });
}

The dirty derived state compares the current form values against the initial snapshot using deep equality checking.

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, Output> {
    key?: ExtractId<Input>;
    schema?: StandardSchemaV1<Input, unknown>;
    data?: Input | $state.Snapshot<Input>;
    resetOn?: "data" | "url";
    initialErrors?: boolean;
    onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => Awaitable<void>;
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    // ... existing setup code ...

    const issues = $derived(configuredForm.fields.issues());
    const allIssues = () => (configuredForm.fields as Fields).allIssues();
    let lastIssues = $state.raw<RemoteFormIssue[] | undefined>(allIssues());

    const debouncedValidate = debounce(validate, 300);

    async function validate() {
        const config = options();
        await configuredForm.validate({ includeUntouched: true, preflightOnly: true });
        const issues = allIssues();
        if (issues && config?.onissues && !deepEqual(lastIssues, issues)) {
            config.onissues({ issues });
        }
        if (issues?.length) lastIssues = issues;
    }

    onMount(() => {
        const config = options();
        if (config?.initialErrors) validate();
    });

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit }) => {
                await submit();
            }),
            oninput: () => {
                if (lastIssues) debouncedValidate.call();
            }
        },
        dirty
    });
}

Validation runs on mount if initialErrors is true, and debounced validation triggers on input when there are existing issues. The onissues callback notifies consumers of validation issues.

Enhanced Submission Handling

Now let’s add comprehensive submission handling with success/error states and callbacks:

export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    key?: ExtractId<Input>;
    schema?: StandardSchemaV1<Input, unknown>;
    data?: Input | $state.Snapshot<Input>;
    resetOn?: "data" | "url";
    initialErrors?: boolean;
    onsubmit?: <T>(ctx: { readonly tainted: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>;
    onresult?: (ctx: {
        readonly success: boolean;
        readonly result?: RemoteForm<Input, Output>["result"];
        readonly issues?: RemoteFormIssue[];
        readonly error?: unknown;
    }) => Awaitable<void>;
    onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => Awaitable<void>;
}

export interface ConfiguredForm<Input extends RemoteFormInput, Output> {
    (): {
        form: Omit<RemoteForm<Input, Output>, "for">;
        attrs: {
            [attachment: symbol]: (node: HTMLFormElement) => void;
            method: "POST";
            action: string;
            oninput: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => void;
        };
        result: Output | undefined;
        dirty: boolean;
        submitting: boolean;
    };
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form);
    let initial = $state.raw($state.snapshot(options().data));
    let submitting = $state.raw(false);

    // ... existing setup code ...

    const result = $derived(configuredForm.result);

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit, form: formEl, data }) => {
                const config = options();
                const bf = !config.onsubmit || (await config.onsubmit({ tainted: dirty, form: formEl, data }));
                if (!bf) return;
                // [!code ++]
                submitting = true;
                const wasDirty = dirty;
                try {
                    dirty = false;
                    await submit();
                    // [!code ++]
                    const issues = allIssues();
                    const success = !issues?.length;
                    // [!code ++]
                    config.onresult?.({ success, result: configuredForm.result, issues });
                    // [!code ++]
                    if (!success) {
                        dirty = wasDirty;
                        config.onissues?.({ issues });
                    }
                } catch (error) {
                    config.onresult?.({ success: false, error });
                    dirty = wasDirty;
                } finally {
                    submitting = false;
                }
            }),
            oninput: (ev) => {
                const config = options();
                if (lastIssues) debouncedValidate.call();
                config.oninput?.(ev);
            }
        },
        result,
        dirty,
        submitting
    });
}

The submission handler:

  • Calls onsubmit before submission (can cancel by returning false)
  • Tracks submitting state
  • Handles success/error cases
  • Calls onresult with the outcome
  • Restores dirty state if submission fails

Navigation Blocking

Prevent accidental navigation when there are unsaved changes:

import { beforeNavigate } from "$app/navigation";
// ... existing imports ...

export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    key?: ExtractId<Input>;
    schema?: StandardSchemaV1<Input, unknown>;
    data?: Input | $state.Snapshot<Input>;
    resetOn?: "data" | "url";
    initialErrors?: boolean;
    navBlockMessage?: string;
    onsubmit?: <T>(ctx: { readonly tainted: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>;
    onresult?: (ctx: {
        readonly success: boolean;
        readonly result?: RemoteForm<Input, Output>["result"];
        readonly issues?: RemoteFormIssue[];
        readonly error?: unknown;
    }) => Awaitable<void>;
    onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => Awaitable<void>;
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    // ... existing code ...

    beforeNavigate((ev) => {
        const config = options();
        if (!config?.navBlockMessage) return;
        if ((dirty || issues) && !confirm(config.navBlockMessage)) {
            return ev.cancel();
        }
    });

    return () => ({
        // ... previous return ...
    });
}

The beforeNavigate hook checks if the form is dirty or has issues, and prompts the user before allowing navigation.

Error Handling and Focus Management

Finally, let’s add automatic focus to invalid fields and toast notifications:

import { tick } from "svelte";
import { toast } from "svelte-sonner";
import { Duration } from "effect";
import { isHttpError } from "@sveltejs/kit";
// ... existing imports ...

export function successToast(message: string) {
    toast.success("Success", {
        description: message,
        classes: {
            description: "text-white!"
        }
    });
}

export function errorToast(message: string) {
    toast.error("Error", {
        description: message,
        classes: {
            description: "text-white!"
        },
        duration: Duration.toMillis("30 seconds")
    });
}

export function unknownErrorMessage(error: unknown): string {
    if (typeof error === "string") return error;
    else if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
    else if (isHttpError(error)) return error.body.message;
    else return "An unknown error occurred";
}

export function unknownErrorToast(error: unknown) {
    errorToast(unknownErrorMessage(error));
}

export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    // ... existing code ...

    async function focusInvalid(formEl: HTMLFormElement) {
        await tick();
        // [!code ++]
        const issues = allIssues();
        if (issues?.length) lastIssues = issues;
        else return;
        // [!code ++]
        const invalid = formEl.querySelector(":is(input, select, textarea):not(.hidden, [type=hidden], :disabled)[aria-invalid]") as
            | HTMLInputElement
            | HTMLSelectElement
            | HTMLTextAreaElement
            | null;
        invalid?.focus();
    }

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit, form: formEl, data }) => {
                const config = options();
                const bf = !config.onsubmit || (await config.onsubmit({ tainted: dirty, form: formEl, data }));
                if (!bf) return;

                submitting = true;
                const wasDirty = dirty;
                try {
                    dirty = false;
                    await submit();

                    const issues = allIssues();
                    const success = !issues?.length;

                    config.onresult?.({ success, result: configuredForm.result, issues });

                    if (success) {
                        successToast(`${(configuredForm.fields as Fields).name?.value() || "Form"} saved successfully`);
                    } else {
                        dirty = wasDirty;
                        await focusInvalid(formEl);
                        config.onissues?.({ issues });
                    }
                } catch (error) {
                    unknownErrorToast(error || "Oh no! Something went wrong");
                    config.onresult?.({ success: false, error });
                    dirty = wasDirty;
                } finally {
                    submitting = false;
                }
            }),
            onsubmit: (ev) => focusInvalid(ev.currentTarget),
            oninput: (ev) => {
                const config = options();
                if (lastIssues) debouncedValidate.call();
                config.oninput?.(ev);
            }
        },
        result,
        dirty,
        submitting
    });
}

The focusInvalid function finds the first invalid field and focuses it, improving accessibility. Toast notifications provide user feedback on success and errors.

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
  4. Dirty state tracking – Know when forms have been modified
  5. Reactive updates – Respond to data or URL changes
  6. Validation – Debounced validation with issue tracking
  7. Submission handling – Comprehensive success/error handling
  8. Navigation blocking – Prevent accidental data loss
  9. Focus management – Auto-focus invalid fields
  10. User feedback – Toast notifications for success/error
  11. Callbacks – Flexible hooks for customization

Here’s the complete implementation:

import { beforeNavigate } from "$app/navigation";
import { page } from "$app/state";
import { debounce, deepEqual } from "@sillvva/utils";
import type { StandardSchemaV1 } from "@standard-schema/spec";
import { isHttpError, type RemoteForm, type RemoteFormFields, type RemoteFormInput, type RemoteFormIssue } from "@sveltejs/kit";
import { Duration } from "effect";
import { onMount, tick, untrack } from "svelte";
import { toast } from "svelte-sonner";
import { v7 } from "uuid";

// Helper functions for toast notifications
export function successToast(message: string) {
    toast.success("Success", {
        description: message,
        classes: {
            description: "text-white!"
        }
    });
}

export function errorToast(message: string) {
    toast.error("Error", {
        description: message,
        classes: {
            description: "text-white!"
        },
        duration: Duration.toMillis("30 seconds")
    });
}

export function unknownErrorMessage(error: unknown): string {
    if (typeof error === "string") return error;
    else if (typeof error === "object" && error !== null && "message" in error && typeof error.message === "string") return error.message;
    else if (isHttpError(error)) return error.body.message;
    else return "An unknown error occurred";
}

export function unknownErrorToast(error: unknown) {
    errorToast(unknownErrorMessage(error));
}

// Type utilities
export type ExtractId<Input> = Input extends { id: infer Id } ? (Id extends string | number ? Id : string | number) : string | number;

// Options interface
export interface RemoteFormOptions<Input extends RemoteFormInput, Output> {
    key?: ExtractId<Input>;
    schema?: StandardSchemaV1<Input, unknown>;
    data?: Input | $state.Snapshot<Input>;
    initialErrors?: boolean;
    resetOn?: "data" | "url";
    navBlockMessage?: string;
    onsubmit?: <T>(ctx: { readonly tainted: boolean; readonly form: HTMLFormElement; readonly data: Input }) => Awaitable<T>;
    oninput?: (ev: Event & { currentTarget: EventTarget & HTMLFormElement }) => void;
    onresult?: (ctx: {
        readonly success: boolean;
        readonly result?: RemoteForm<Input, Output>["result"];
        readonly issues?: RemoteFormIssue[];
        readonly error?: unknown;
    }) => Awaitable<void>;
    onissues?: (ctx: { readonly issues: RemoteFormIssue[] }) => Awaitable<void>;
}

// Return type interface
export interface ConfiguredForm<Input extends RemoteFormInput, Output> {
    (): {
        form: Omit<RemoteForm<Input, Output>, "for">;
        attrs: {
            [attachment: symbol]: (node: HTMLFormElement) => void;
            method: "POST";
            action: string;
            onsubmit: (
                ev: Event & {
                    currentTarget: EventTarget & HTMLFormElement;
                }
            ) => Promise<void>;
            oninput: (
                ev: Event & {
                    currentTarget: EventTarget & HTMLFormElement;
                }
            ) => void;
        };
        result: Output | undefined;
        dirty: boolean;
        submitting: boolean;
    };
}

// Main factory function
export function configureForm<Input extends RemoteFormInput, Output>(
    form: RemoteForm<Input, Output>,
    options = () => ({} as RemoteFormOptions<Input, Output>)
): ConfiguredForm<Input, Output> {
    let configuredForm: Omit<RemoteForm<Input, Output>, "for"> = $state.raw(form);
    let initial = $state.raw($state.snapshot(options().data));
    let submitting = $state.raw(false);

    type Fields = RemoteFormFields<unknown>;

    function setup(config: RemoteFormOptions<Input, Output>) {
        initial = $state.snapshot(config?.data);

        configuredForm = form.for((config.key ?? config.data?.id ?? v7()) as ExtractId<Input>);

        if (config?.schema) {
            configuredForm = configuredForm.preflight(config.schema as unknown as StandardSchemaV1<Input, unknown>);
        }

        if (config.data) {
            (configuredForm.fields as Fields).set(config.data);
        }
    }

    setup(options());
    let hydrated = false;
    $effect(() => {
        const config = untrack(options);

        const updateOn = config.resetOn ?? (untrack(() => config.data) ? "data" : "url");
        if (updateOn === "data") void options().data;
        else void page.url;

        if (!hydrated) return void (hydrated = true);
        untrack(() => setup(config));
    });

    let dirty = $derived(!deepEqual(initial, $state.snapshot(configuredForm.fields.value())));

    const result = $derived(configuredForm.result);
    const issues = $derived(configuredForm.fields.issues());
    const allIssues = () => (configuredForm.fields as Fields).allIssues();
    let lastIssues = $state.raw<RemoteFormIssue[] | undefined>(allIssues());

    const debouncedValidate = debounce(validate, 300);

    async function validate() {
        const config = options();
        await configuredForm.validate({ includeUntouched: true, preflightOnly: true });
        const issues = allIssues();
        if (issues && config?.onissues && !deepEqual(lastIssues, issues)) config.onissues({ issues });
        if (issues?.length) lastIssues = issues;
    }

    async function focusInvalid(formEl: HTMLFormElement) {
        await tick();

        const issues = allIssues();
        if (issues?.length) lastIssues = issues;
        else return;

        const invalid = formEl.querySelector(":is(input, select, textarea):not(.hidden, [type=hidden], :disabled)[aria-invalid]") as
            | HTMLInputElement
            | HTMLSelectElement
            | HTMLTextAreaElement
            | null;
        invalid?.focus();
    }

    onMount(() => {
        const config = options();
        if (config?.initialErrors) validate();
    });

    beforeNavigate((ev) => {
        const config = options();
        if (!config?.navBlockMessage) return;
        if ((dirty || issues) && !confirm(config.navBlockMessage)) {
            return ev.cancel();
        }
    });

    return () => ({
        form: configuredForm,
        attrs: {
            ...configuredForm.enhance(async ({ submit, form: formEl, data }) => {
                const config = options();
                const bf = !config.onsubmit || (await config.onsubmit({ tainted: dirty, form: formEl, data }));
                if (!bf) return;

                submitting = true;
                const wasDirty = dirty;
                try {
                    dirty = false;
                    await submit();

                    const issues = allIssues();
                    const success = !issues?.length;

                    config.onresult?.({ success, result: configuredForm.result, issues });

                    if (success) {
                        successToast(`${(configuredForm.fields as Fields).name?.value() || "Form"} saved successfully`);
                    } else {
                        dirty = wasDirty;
                        await focusInvalid(formEl);
                        config.onissues?.({ issues });
                    }
                } catch (error) {
                    unknownErrorToast(error || "Oh no! Something went wrong");
                    config.onresult?.({ success: false, error });
                    dirty = wasDirty;
                } finally {
                    submitting = false;
                }
            }),
            onsubmit: (ev) => focusInvalid(ev.currentTarget),
            oninput: (ev) => {
                const config = options();
                if (lastIssues) debouncedValidate.call();
                config.oninput?.(ev);
            }
        },
        result,
        dirty,
        submitting
    });
}

Usage Example

<script>
    import { configureForm } from "$lib/factories.svelte";
    import { remoteForm } from "./+page.server";

    const configured = configureForm(remoteForm, () => ({
        schema: mySchema,
        data: existingData,
        initialErrors: true,
        navBlockMessage: "You have unsaved changes. Are you sure?",
        onresult: ({ success, result }) => {
            if (success) {
                console.log("Form saved:", result);
            }
        }
    }));

    const { form, attrs, dirty, submitting } = $derived(configured());
</script>

<form {...attrs}>
    <!-- form fields -->
</form>

{#if dirty}
    <p>You have unsaved changes</p>
{/if}

This factory function provides a robust foundation for building forms in SvelteKit applications, handling the common concerns that arise in form development.

Similar Posts