Last active
October 1, 2025 07:52
-
-
Save sillvva/6515b65d85075133de7c2e533b730e98 to your computer and use it in GitHub Desktop.
Remote Functions + Superforms
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import Breadcrumbs from "$lib/components/Breadcrumbs.svelte"; | |
| import Control from "$lib/components/forms/Control.svelte"; | |
| import Input from "$lib/components/forms/Input.svelte"; | |
| import Submit from "$lib/components/forms/Submit.svelte"; | |
| import SuperForm from "$lib/components/forms/SuperForm.svelte"; | |
| import { valibotForm } from "$lib/factories.svelte.js"; | |
| import DMsAPI from "$lib/remote/dms"; | |
| import { dungeonMasterSchema } from "$lib/schemas"; | |
| let { data } = $props(); | |
| // Invalidates all on redirect | |
| const superform = valibotForm(data.form, dungeonMasterSchema, { | |
| remote: DMsAPI.forms.save | |
| }); | |
| // Alternatively with single-flight mutations: | |
| // const superform = valibotForm(data.form, dungeonMasterSchema, { | |
| // remote: (data) => DMsAPI.forms.save(data).updates(getDMs(data.user.id), getDM(data.dm.id)), | |
| // }); | |
| // const superform = valibotForm(data.form, dungeonMasterSchema, { | |
| // remote: DMsAPI.forms.save, | |
| // remoteUpdates: () => ([getDMs(data.user.id), getDM(data.dm.id)]) | |
| // }); | |
| </script> | |
| <div class="flex flex-col gap-4"> | |
| <Breadcrumbs /> | |
| <SuperForm {superform}> | |
| <Control class="col-span-12 sm:col-span-6"> | |
| <Input type="text" {superform} field="name" label="DM Name" /> | |
| </Control> | |
| <Control class="col-span-12 sm:col-span-6"> | |
| <Input type="text" {superform} field="DCI" label="DCI" /> | |
| </Control> | |
| <Submit {superform}>Save DM</Submit> | |
| </SuperForm> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // Success Result | |
| export type EffectSuccess<R> = { ok: true; data: R }; | |
| // Failure Result | |
| export type EffectFailure = { | |
| ok: false; | |
| error: { message: string; status: NumericRange<300, 599>; [key: string]: unknown }; | |
| }; | |
| // Discriminating Union | |
| export type EffectResult<R> = EffectSuccess<R> | EffectFailure; | |
| // Pathname comes from @sveltejs/kit. | |
| export type FullPathname = Pathname | `${Pathname}?${string}`; | |
| // Single Flight Mutation Refreshes | |
| type RemoteUpdates = Array<RemoteQuery<unknown> | RemoteQueryOverride>; | |
| /** | |
| * Configuration options for custom form behavior when using remote functions. | |
| */ | |
| export interface CustomFormOptions<Out extends Record<string, unknown>> { | |
| /** | |
| * Remote function handler that processes form data and returns validation results or redirect paths. | |
| * When provided, the form will use this instead of the default SvelteKit form actions. | |
| */ | |
| remote?: (data: Out) => Promise<EffectResult<SuperValidated<Out> | FullPathname>>; | |
| /** | |
| * Must be undefined for CustomFormOptions. Use OptionsWithUpdates if you need remote updates. | |
| */ | |
| remoteUpdates?: undefined; | |
| /** | |
| * Callback executed when form submission succeeds. Receives the validated form data. | |
| * @param data - The successfully validated form data | |
| */ | |
| onSuccessResult?: (data: Out) => Awaitable<void>; | |
| /** | |
| * Callback executed when form submission fails. Receives the error details. | |
| * @param error - The error that occurred during submission | |
| */ | |
| onErrorResult?: (error: EffectFailure["error"]) => Awaitable<void>; | |
| } | |
| /** | |
| * Configuration options for forms that need to update remote data after successful submission. | |
| * Extends CustomFormOptions but requires remote updates functionality. | |
| */ | |
| export interface OptionsWithUpdates<Out extends Record<string, unknown>> extends Omit<CustomFormOptions<Out>, "remoteUpdates"> { | |
| /** | |
| * Remote function handler with updates capability. Must include an `updates` method | |
| * that can invalidate specific remote queries after successful form submission. | |
| */ | |
| remote?: (data: Out) => Promise<EffectResult<SuperValidated<Out> | FullPathname>> & { | |
| updates: (...queries: RemoteUpdates) => Promise<EffectResult<SuperValidated<Out> | FullPathname>>; | |
| }; | |
| /** | |
| * Array of remote queries to invalidate after successful form submission. | |
| */ | |
| remoteUpdates: () => RemoteUpdates; | |
| } | |
| /** | |
| * Creates a SvelteKit Superforms form with Valibot validation and enhanced remote function capabilities. | |
| * | |
| * This function combines the power of SvelteKit Superforms with Valibot schema validation, providing | |
| * a type-safe form solution that supports both traditional form submission and remote functions. | |
| * It includes built-in error handling, success notifications, and optional query invalidation. | |
| * | |
| * @param form - The SuperValidated form data from SvelteKit Superforms, typically from a load function | |
| * @param schema - The Valibot schema used for client-side validation | |
| * @param options - Configuration options for form behavior, validation, and submission handling | |
| * | |
| * @returns Enhanced SuperForm object with additional properties: | |
| * - All standard SvelteKit Superforms properties (form, errors, message, etc.) | |
| * - `pending` - Writable store indicating if form submission is in progress | |
| * - `submitCount` - Writable store tracking the number of submission attempts | |
| * | |
| * @example | |
| * ```typescript | |
| * // Basic usage with Valibot schema | |
| * const schema = v.object({ | |
| * name: v.string(), | |
| * email: v.pipe(v.string(), v.email()) | |
| * }); | |
| * | |
| * const { form, errors, enhance } = $derived(valibotForm(data.form, schema)); | |
| * | |
| * // Usage in Svelte component | |
| * <form method="POST" use:enhance> | |
| * <input bind:value={$form.name} /> | |
| * {#if $errors.name} | |
| * <span class="error">{$errors.name}</span> | |
| * {/if} | |
| * </form> | |
| * ``` | |
| * | |
| * @example | |
| * ```typescript | |
| * // Advanced usage with remote functions and updates | |
| * const { form, errors, enhance, pending } = $derived(valibotForm(data.form, schema, { | |
| * remote: updateCharacter, | |
| * remoteUpdates: [ | |
| * getCharacters(user.id), | |
| * getCharacter(character.id) | |
| * ] | |
| * })); | |
| * ``` | |
| * | |
| * @see {@link https://superforms.rocks/ SvelteKit Superforms Documentation} | |
| * @see {@link https://valibot.dev/ Valibot Documentation} | |
| */ | |
| export function valibotForm<S extends v.GenericSchema, Out extends Infer<S, "valibot">, In extends InferIn<S, "valibot">>( | |
| form: SuperValidated<Out, App.Superforms.Message, In>, | |
| schema: S, | |
| { | |
| remote, | |
| remoteUpdates, | |
| invalidateAll: inalidate, | |
| onSuccessResult = (data) => (typeof data === "object" && "name" in data ? successToast(`${data.name} saved`) : undefined), | |
| onErrorResult = (error) => errorToast(error.message), | |
| onSubmit, | |
| onResult, | |
| onError, | |
| ...rest | |
| }: FormOptions<Out, App.Superforms.Message, In> & (CustomFormOptions<Out> | OptionsWithUpdates<Out>) = {} | |
| ) { | |
| // Create reactive stores for form state management | |
| const pending = writable(false); | |
| const submitCount = writable(0); | |
| // Initialize the SvelteKit Superforms instance with Valibot validation | |
| const superform = superForm(form, { | |
| dataType: "json", // Use JSON for form data transmission | |
| validators: valibotClient(schema), // Integrate Valibot schema for client-side validation | |
| taintedMessage: "You have unsaved changes. Are you sure you want to leave?", | |
| ...rest, // Spread any additional Superforms options | |
| // Custom submit handler that supports both traditional and remote function | |
| onSubmit: async (event) => { | |
| // Update form state to indicate submission is in progress | |
| pending.set(true); | |
| submitCount.update((count) => count + 1); | |
| // Execute custom onSubmit callback if provided | |
| // If the callback returns false, cancel the submission | |
| if ((await onSubmit?.(event)) === false) { | |
| pending.set(false); | |
| return event.cancel(); | |
| } | |
| // Handle remote function if a remote handler is provided | |
| if (remote) { | |
| // Cancel the default form submission since we're handling it remotely | |
| event.cancel(); | |
| // Get current form data and execute remote function | |
| const data = get(superform.form); | |
| const result = (remoteUpdates && (await remote(data).updates(...remoteUpdates()))) ?? (await remote(data)); | |
| if (result.ok) { | |
| // Handle successful remote function submission | |
| if (typeof result.data === "string") { | |
| // String result indicates a redirect path (FullPathname) | |
| superform.tainted.set(undefined); // Clear tainted state | |
| await onSuccessResult(data); // Execute success callback | |
| await goto(result.data); // Navigate to the specified path | |
| return; | |
| } | |
| // Handle validation result from remote function | |
| const hasErrors = Object.keys(result.data.errors).length > 0; | |
| superform.errors.set(result.data.errors); // Update form errors | |
| superform.message.set(result.data.message); // Update form message | |
| superform.form.set(result.data.data, { | |
| taint: hasErrors ? true : "untaint-form" // Manage form taint state | |
| }); | |
| // Execute success callback only if there are no validation errors | |
| if (!hasErrors) await onSuccessResult(data); | |
| } else { | |
| // Handle remote function failure | |
| await onErrorResult(result.error); // Execute error callback | |
| if (isRedirectFailure(result.error)) { | |
| // Handle redirect failure (e.g., authentication required) | |
| superform.tainted.set(undefined); // Clear tainted state | |
| await goto(result.error.redirectTo); // Navigate to redirect path | |
| return; | |
| } else { | |
| // Handle other types of errors | |
| const error = result.error.message; | |
| superform.errors.set({ _errors: [error] }); // Set form-level error | |
| } | |
| } | |
| pending.set(false); // Clear pending state | |
| } | |
| }, | |
| // Handle form result events (success, failure, redirect) | |
| onResult(event) { | |
| // Clear pending state for non-redirect results | |
| if (event.result.type !== "redirect") pending.set(false); | |
| // Execute success callback for successful submissions or redirects | |
| if (["success", "redirect"].includes(event.result.type)) { | |
| onSuccessResult(get(superform.form)); | |
| } | |
| // Execute custom onResult callback if provided | |
| onResult?.(event); | |
| } | |
| }); | |
| // Cleanup: invalidate all data when component is destroyed (if conditions are met) | |
| onDestroy(async () => { | |
| if (get(submitCount) > 0 && inalidate !== false && !remoteUpdates?.length) { | |
| await invalidateAll(); | |
| } | |
| }); | |
| // Return enhanced SuperForm with additional state management | |
| return { | |
| ...superform, // Spread all standard Superforms properties | |
| pending, // Reactive store for submission state | |
| submitCount // Reactive store for submission attempt count | |
| }; | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // $lib/remotes/dms/forms.remote.ts | |
| import { command } from "$app/server"; | |
| import { dungeonMasterIdSchema, dungeonMasterSchema, type DungeonMasterSchemaIn } from "$lib/schemas"; | |
| import { redirectOnFail } from "$lib/server/effect/errors"; | |
| import { parse, saveForm, validateForm } from "$lib/server/effect/forms"; | |
| import { runSafe } from "$lib/server/effect/runtime"; | |
| import { assertAuth } from "$lib/server/effect/services/auth"; | |
| import { DMService } from "$lib/server/effect/services/dms"; | |
| // Skip built-in validation | |
| export const save = command("unchecked", (input: DungeonMasterSchemaIn) => | |
| // returns EffectResult type (see factories.svelte.ts) | |
| runSafe(function* () { | |
| const { user } = yield* assertAuth(); | |
| const DMs = yield* DMService; | |
| const dmId = yield* redirectOnFail(parse(dungeonMasterIdSchema, input.id), "/dms", 301); | |
| // Superforms validation | |
| const form = yield* validateForm(input, dungeonMasterSchema); | |
| if (!form.valid) return form; | |
| // Returns form or redirect path | |
| return yield* saveForm(DMs.set.save(dmId, user, form.data), { | |
| onSuccess: () => "/dms", | |
| onError: (err) => { | |
| err.toForm(form); | |
| return form; | |
| } | |
| }); | |
| }) | |
| ); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import { dev } from "$app/environment"; | |
| import { onMount, type Snippet } from "svelte"; | |
| import { fromAction } from "svelte/attachments"; | |
| import type { HTMLFormAttributes } from "svelte/elements"; | |
| import type { Writable } from "svelte/store"; | |
| import type { SuperForm } from "sveltekit-superforms"; | |
| import SuperDebug from "sveltekit-superforms/SuperDebug.svelte"; | |
| import FormMessage from "./FormMessage.svelte"; | |
| type FormAttributes = Omit<HTMLFormAttributes, "hidden">; | |
| type T = $$Generic<Record<PropertyKey, unknown>>; | |
| interface Props extends Omit<FormAttributes, "action"> { | |
| superform: SuperForm<T, App.Superforms.Message> & { pending: Writable<boolean> }; | |
| children?: Snippet; | |
| } | |
| let { superform, children, ...rest }: Props = $props(); | |
| const { form, errors, message, enhance, capture, restore, submitting, tainted, pending } = superform; | |
| const isSubmitting = $derived($submitting || $pending); | |
| onMount(() => { | |
| superform.reset(); | |
| }); | |
| export const snapshot = { | |
| capture, | |
| restore | |
| }; | |
| </script> | |
| <div class="flex flex-col gap-4"> | |
| <FormMessage {message} /> | |
| {#if $errors._errors?.[0]} | |
| <div class="alert alert-error shadow-lg"> | |
| <span class="iconify mdi--alert-circle size-6"></span> | |
| {$errors._errors[0]} | |
| </div> | |
| {/if} | |
| <form {...rest} method="post" {@attach fromAction(enhance)}> | |
| <fieldset class="grid grid-cols-12 gap-4" disabled={isSubmitting}> | |
| {@render children?.()} | |
| </fieldset> | |
| </form> | |
| {#if dev} | |
| <SuperDebug data={{ $form, $errors, $message, isSubmitting, $tainted }} /> | |
| {/if} | |
| </div> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment