Forms and fields in components
Looking at the rather simple get started tutorial, it’s obvious that quite a bit of boilerplate code adds up for a Superform:
<!-- For each form field -->
<label for="name">Name</label>
<input
type="text"
name="name"
aria-invalid={$errors.name ? 'true' : undefined}
bind:value={$form.name}
{...$constraints.name}
/>
{#if $errors.name}
<span class="invalid">{$errors.name}</span>
{/if}
And it also gets bad in the script part when you have more than a couple of forms on the page:
<script lang="ts">
import { superForm } from 'sveltekit-superforms'
let { data } = $props();
const {
form: loginForm,
errors: loginErrors,
enhance: loginEnhance,
//...
} = superForm(data.loginForm);
const {
form: registerForm,
errors: registerErrors,
enhance: registerEnhance,
// ...
} = superForm(data.registerForm);
</script>
This leads to the question of whether a form and its fields can be factored out into components?
Factoring out a form
To do this, you need the type of the schema, which can be defined as follows:
src/lib/schemas.ts
export const loginSchema = z.object({
email: z.string().email(),
password: // ...
});
export type LoginSchema = typeof loginSchema;
Now you can import and use this type in a separate component:
src/routes/LoginForm.svelte
<script lang="ts">
import type { SuperValidated, Infer } from 'sveltekit-superforms';
import { superForm } from 'sveltekit-superforms'
import type { LoginSchema } from '$lib/schemas';
let { data } : { data : SuperValidated<Infer<LoginSchema>> } = $props();
const { form, errors, enhance } = superForm(data);
</script>
<form method="POST" use:enhance>
<!-- Business as usual -->
</form>
SuperValidated is the return type from superValidate, which we called in the load function.
This component can now be passed the SuperValidated
form data (from the PageData
we returned from +page.server.ts
), making the page much less cluttered:
+page.svelte
<script lang="ts">
let { data } = $props();
</script>
<LoginForm data={data.loginForm} />
<RegisterForm data={data.registerForm} />
If your schema input and output types differ, or you have a strongly typed status message, you can add two additional type parameters:
<script lang="ts">
import type { SuperValidated, Infer, InferIn } from 'sveltekit-superforms';
import { superForm } from 'sveltekit-superforms'
import type { LoginSchema } from '$lib/schemas';
let { data } : {
data : SuperValidated<Infer<LoginSchema>, { status: number, text: string }, InferIn<LoginSchema>>
} = $props();
const { form, errors, enhance, message } = superForm(data);
</script>
{#if $message.text}
...
{/if}
<form method="POST" use:enhance>
<!-- Business as usual -->
</form>
Factoring out form fields
Since bind
is available on Svelte components, we can make a TextInput
component quite easily:
TextInput.svelte
<script lang="ts">
import type { InputConstraint } from 'sveltekit-superforms';
let { value, label, errors, constraints } : {
value: string;
label: string | undefined = undefined;
errors: string[] | undefined = undefined;
constraints: InputConstraint | undefined = undefined;
} = $props();
</script>
<label>
{#if label}<span>{label}</span><br />{/if}
<input
type="text"
bind:value
aria-invalid={errors ? 'true' : undefined}
{...constraints}
{...$$restProps}
/>
</label>
{#if errors}<span class="invalid">{errors}</span>{/if}
+page.svelte
<form method="POST" use:enhance>
<TextInput
name="name"
label="name"
bind:value={$form.name}
errors={$errors.name}
constraints={$constraints.name}
/>
<h4>Tags</h4>
{#each $form.tags as _, i}
<TextInput
name="tags"
label="Name"
bind:value={$form.tags[i].name}
errors={$errors.tags?.[i]?.name}
constraints={$constraints.tags?.name}
/>
{/each}
</form>
(Note that you must bind directly to $form.tags
with the index, you cannot use the each loop variable, hence the underscore.)
This is a bit better and will certainly help when the components require some styling, but there are still plenty of attributes. Can we do even better?
Using a fieldProxy
You may have seen proxy objects being used for converting an input field string like "2023-04-12"
into a Date
, but that’s a special usage of proxies. They can actually be used for any part of the form data, to have a store that can modify a part of the $form
store. If you want to update just $form.name
, for example:
<script lang="ts">
import { superForm, fieldProxy } from 'sveltekit-superforms/client';
let { data } = $props();
const { form } = superForm(data.form);
const name = fieldProxy(form, 'name');
</script>
<div>Name: {$name}</div>
<button on:click={() => ($name = '')}>Clear name</button>
Any updates to $name
will reflect in $form.name
, and vice versa. Note that this will also taint that field, so if this is not intended, you can use the whole superForm object and add an option:
const superform = superForm(data.form);
const { form } = superform;
const name = fieldProxy(superform, 'name', { taint: false });
A fieldProxy
isn’t enough here, however. We’d still have to make proxies for form
, errors
, and constraints
, resulting in the same problem again.
Using a formFieldProxy
The solution is to use a formFieldProxy
, which is a helper function for producing the above proxies from a form. To do this, we cannot immediately deconstruct what we need from superForm
, since formFieldProxy
takes the form itself as an argument:
<script lang="ts">
import type { PageData } from './$types.js';
import { superForm, formFieldProxy } from 'sveltekit-superforms/client';
let { data } : { data: PageData } = $props();
const superform = superForm(data.form);
const { path, value, errors, constraints } = formFieldProxy(superform, 'name');
</script>
But we didn’t want to pass all those proxies, so let’s imagine a component that will handle even the above proxy creation for us.
A typesafe, generic component
<TextField {superform} field="name" />
How nice would this be? This can actually be pulled off in a typesafe way with a bit of Svelte magic:
<script lang="ts" context="module">
type T = Record<string, unknown>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>">
import { formFieldProxy, type SuperForm, type FormPathLeaves } from 'sveltekit-superforms';
let { superForm, field } : { superform: SuperForm<T>, field: FormPathLeaves<T> } = $props();
const { value, errors, constraints } = formFieldProxy(superform, field);
</script>
<label>
{field}<br />
<input
name={field}
type="text"
aria-invalid={$errors ? 'true' : undefined}
bind:value={$value}
{...$constraints}
{...$$restProps} />
</label>
{#if $errors}<span class="invalid">{$errors}</span>{/if}
The Svelte syntax requires Record<string, unknown>
to be defined before its used in the generics
attribute, so we have to import it in a module context. Now when T
is defined (the schema object type), we can use it in the form
prop to ensure that only a SuperForm
matching the field
prop is used.
Type narrowing for paths
Checkboxes don’t bind with bind:value
but with bind:checked
, which requires a boolean
.
Because our component is generic, value
returned from formFieldProxy
is unknown, but we need a boolean
here. Then we can add a type parameter to FormPathLeaves
to narrow it down to a specific type, and use the satisfies operator to specify the type:
<script lang="ts" context="module">
type T = Record<string, unknown>;
</script>
<script lang="ts" generics="T extends Record<string, unknown>">
import {
formFieldProxy, type FormFieldProxy,
type SuperForm, type FormPathLeaves
} from 'sveltekit-superforms';
let { superForm, field } : { superform: SuperForm<T>, field: FormPathLeaves<T, boolean> } = $props();
const { value, errors, constraints } = formFieldProxy(superform, field) satisfies FormFieldProxy<boolean>;
</script>
<input
name={field}
type="checkbox"
class="checkbox"
bind:checked={$value}
{...$constraints}
{...$$restProps}
/>
This will also narrow the field
prop, so only boolean
fields in the schema can be selected when using the component.
Checkboxes, especially grouped ones, can be tricky to handle. Read the Svelte tutorial about bind:group, and see the Ice cream example on Stackblitz if you’re having trouble with it.
Using the componentized field in awesome ways
Using this component is now as simple as:
<TextField {superform} field="name" />
But to show off some super proxy power, let’s recreate the tags example above with the TextField
component:
<form method="POST" use:enhance>
<TextField name="name" {superform} field="name" />
<h4>Tags</h4>
{#each $form.tags as _, i}
<TextField name="tags" {superform} field="tags[{i}].name" />
{/each}
</form>
We can now produce a type-safe text field for any object inside our data, which will update the $form
store, and to add new tags, just append a tag object to the tags array:
$form.tags = [...$form.tags, { id: undefined, name: '' }];
In general, nested data requires the dataType
option to be set to 'json'
, except arrays of primitive values, which are coerced automatically.
I hope you now feel under your fingers the superpowers that Superforms bring! 💥