Nested data
HTML forms can only handle string values, and the <form>
element cannot nest other forms, so there is no standardized way to represent a nested data structure or more complex values like dates. Fortunately, Superforms has a solution for this!
Usage
const { form, enhance } = superForm(data.form, {
dataType: 'form' | 'json' = 'form'
})
dataType
By simply setting dataType
to 'json'
, you can store any data structure allowed by devalue in the $form
store, and you don’t have to worry about failed coercion, converting strings to objects and arrays, etc.
You don’t even have to set a name on the form fields anymore, since the actual data in $form
is now posted, not the input fields in the HTML. They are now simply UI components for modifying a data model, as they should be. (Name attributes can still be useful for the browser to pre-fill fields though.)
Modifying the form
store programmatically is easy, you can assign $form.field = ...
directly, but note that this will taint the affected fields. If you want to prevent the form from being tainted, you can use form.update
with an extra option:
form.update(
($form) => {
$form.name = "New name";
return $form;
},
{ taint: false }
);
The taint
options are:
{ taint: boolean | 'untaint' | 'untaint-form' }
Which can be used to not only prevent tainting, but also untaint the modified field(s), or the whole form.
Requirements
The requirements for nested data to work are as follows:
- JavaScript is enabled in the browser.
- The form has the Superforms use:enhance applied.
Nested errors and constraints
When your schema contains arrays or objects, you can access them through $form
as an ordinary object. But how does it work with errors and constraints?
$errors
and $constraints
actually mirror the $form
data, but with every field or “leaf” in the object replaced with string[]
and InputConstraints
respectively.
Example
Given the following schema, which describes an array of tag objects:
const schema = z.object({
tags: z
.object({
id: z.number().int().min(1),
name: z.string().min(2)
})
.array()
});
const tags = [{ id: 1, name: 'test' }];
export const load = async () => {
const form = await superValidate({ tags }, zod(schema));
return { form };
};
You can build a HTML form for these tags using an {#each}
loop:
<script lang="ts">
const { form, errors, enhance } = superForm(data.form, {
dataType: 'json'
});
</script>
<form method="POST" use:enhance>
{#each $form.tags as _, i}
<div>
Id
<input
type="number"
data-invalid={$errors.tags?.[i]?.id}
bind:value={$form.tags[i].id} />
Name
<input
data-invalid={$errors.tags?.[i]?.name}
bind:value={$form.tags[i].name} />
{#if $errors.tags?.[i]?.id}
<br />
<span class="invalid">{$errors.tags[i].id}</span>
{/if}
{#if $errors.tags?.[i]?.name}
<br />
<span class="invalid">{$errors.tags[i].name}</span>
{/if}
</div>
{/each}
<button>Submit</button>
</form>
Note that we’re using the index of the loop, so the value can be bound directly to $form
. You can’t use the each loop variable, hence the underscore. This is what it looks like:
Arrays with primitive values
It’s possible to post multiple HTML elements with the same name, so you don’t have to use dataType: 'json'
for arrays of primitive values like numbers and strings. Just add the input fields, all with the same name as the schema field, which can only be at the top level of the schema. Superforms will handle the type coercion to array automatically, as long as the fields have the same name attribute:
export const schema = z.object({
tags: z.string().min(2).array().max(3)
});
<script lang="ts">
const { form, errors } = superForm(data.form);
</script>
<form method="POST">
<div>Tags</div>
{#if $errors.tags?._errors}
<div class="invalid">{$errors.tags._errors}</div>
{/if}
{#each $form.tags as _, i}
<div>
<input name="tags" bind:value={$form.tags[i]} />
{#if $errors.tags?.[i]}
<span class="invalid">{$errors.tags[i]}</span>
{/if}
</div>
{/each}
<button>Submit</button>
</form>
To summarize, the index i
of the #each
loop is used to access $form.tags
, where the current values are (you cannot use the loop variable), and then the name
attribute is set to the schema field tags
, so its array will be populated when posted.
This example, having a max(3)
limitation of the number of tags, also shows how to display array-level errors with the $errors.tags._errors
field.
Validation schemas and nested paths
Validation libraries like Zod can refine the validation, the classic example is to check if two password fields are identical when updating a password. Usually there’s a path
specifier for setting errors on those fields in the refine function:
const confirmPassword = z
.object({
password: z.string(),
confirm: z.string(),
})
.refine((data) => data.password === data.confirm, {
message: "Passwords don't match",
path: ["confirm"], // path of error
});
This works fine for top-level properties, but for nested data you must usually specify that path as an array, each segment in its own element, not as a string path as you can do in the FormPathLeaves
type!
// OK:
path: ['form', 'tags', 3]
// Will not work with Zod refine and superRefine:
path ['form.tags[3]']