Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
2c1f8cf
feat: better remote form field interactions
dummdidumm Sep 19, 2025
fec866b
value() / value(...) with correct POJO at all levels
dummdidumm Sep 19, 2025
6558c45
WIP has(...)
dummdidumm Sep 19, 2025
53dca13
Merge branch 'main' into remote-form-api-tweaks
dummdidumm Sep 23, 2025
e8a0bbe
get value(input?) working
dummdidumm Sep 23, 2025
43fc177
remove name method
Rich-Harris Sep 26, 2025
7fb0080
remove as(...) from non-leaves, implement allIssues
Rich-Harris Sep 26, 2025
ebdb523
allow schema to contain number/boolean
Rich-Harris Sep 26, 2025
f7e0aef
snake_case
Rich-Harris Sep 26, 2025
517bad4
coerce numbers and booleans
Rich-Harris Sep 26, 2025
6a1e289
redact implementation details
Rich-Harris Sep 26, 2025
fa923e1
don't return input to client unnecessarily, only redact sensitive fie…
Rich-Harris Sep 26, 2025
3cedbde
toggle checkboxes off
Rich-Harris Sep 26, 2025
62f4f22
wip
dummdidumm Sep 30, 2025
710b212
docs
dummdidumm Oct 1, 2025
b37f729
more coercion
dummdidumm Oct 1, 2025
c49c06a
fix types
dummdidumm Oct 1, 2025
fe4809d
handle checkbox arrays, disallow radio arrays
dummdidumm Oct 1, 2025
4728b77
as('select'), initial(...) (TODO, do we really want it?), preserve se…
dummdidumm Oct 3, 2025
0fa7b19
merge, update tests
Rich-Harris Oct 4, 2025
0caa663
remove initial(...) method
Rich-Harris Oct 4, 2025
f7b7c7f
fix most tests
Rich-Harris Oct 4, 2025
d2a6345
fix
Rich-Harris Oct 4, 2025
99df4cd
get checkboxes working properly
Rich-Harris Oct 4, 2025
4437df1
various
Rich-Harris Oct 4, 2025
c2ae99d
fix and DRY out error messages
Rich-Harris Oct 4, 2025
07ef0d8
fix radio inputs
Rich-Harris Oct 5, 2025
58e36ac
document fields
Rich-Harris Oct 5, 2025
2e3e92b
more docs
Rich-Harris Oct 5, 2025
43c3cf1
tweak
Rich-Harris Oct 5, 2025
0604e59
fix docs
Rich-Harris Oct 5, 2025
4a89ba6
fix
Rich-Harris Oct 5, 2025
13e7dcd
changeset
Rich-Harris Oct 5, 2025
c3c7d75
Update .changeset/shaky-ties-look.md
Rich-Harris Oct 5, 2025
6ebc160
allow fields.set to work during SSR
Rich-Harris Oct 5, 2025
a14f973
we can get rid of file_transport since we're sanitizing issues
Rich-Harris Oct 5, 2025
072af68
Merge branch 'remote-form-api-tweaks' of github.com:sveltejs/kit into…
Rich-Harris Oct 5, 2025
eb3c61e
bit more consistency in naming
Rich-Harris Oct 5, 2025
892622d
expose RemoteFormField and RemoteFormFieldValue
Rich-Harris Oct 5, 2025
9b9de0e
expose RemoteFormFieldType
Rich-Harris Oct 5, 2025
6a91e81
Update packages/kit/src/runtime/form-utils.svelte.js
Rich-Harris Oct 5, 2025
7d7fed4
tighten up types
Rich-Harris Oct 5, 2025
64df462
Merge branch 'remote-form-api-tweaks' of github.com:sveltejs/kit into…
Rich-Harris Oct 5, 2025
e1dc8b8
tidy up
Rich-Harris Oct 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/shaky-ties-look.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

breaking: update experimental `form` API
254 changes: 173 additions & 81 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,6 @@ export const getWeather = query.batch(v.string(), async (cities) => {

The `form` function makes it easy to write data to the server. It takes a callback that receives `data` constructed from the submitted [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData)...


```ts
/// file: src/routes/blog/data.remote.js
// @filename: ambient.d.ts
Expand Down Expand Up @@ -293,115 +292,188 @@ export const createPost = form(

<h1>Create a new post</h1>

<form {...createPost}>
<!-- form content goes here -->

<button>Publish!</button>
</form>
```

The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an [attachment](/docs/svelte/@attach) that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page.

As with `query`, if the callback uses the submitted `data`, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `form`.

### Fields

A form is composed of a set of _fields_, which are defined by the schema. In the case of `createPost`, we have two fields, `title` and `content`, which are both strings. To get the attributes for a field, call its `.as(...)` method, specifying which [input type](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/input#input_types) to use:

```svelte
<form {...createPost}>
<label>
<h2>Title</h2>
<input name="title" />
+++<input {...createPost.fields.title.as('text')} />+++
</label>

<label>
<h2>Write your post</h2>
<textarea name="content"></textarea>
+++<textarea {...createPost.fields.content.as('text')}></textarea>+++
</label>

<button>Publish!</button>
</form>
```

As with `query`, if the callback uses the submitted `data`, it should be [validated](#query-Query-arguments) by passing a [Standard Schema](https://standardschema.dev) as the first argument to `form`. The one difference is to `query` is that the schema inputs must all be of type `string` or `File`, since that's all the original `FormData` provides. You can however coerce the value into a different type — how to do that depends on the validation library you use.
These attributes allow SvelteKit to set the correct input type, set a `name` that is used to construct the `data` passed to the handler, populate the `value` of the form (for example following a failed submission, to save the user having to re-enter everything), and set the [`aria-invalid`](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid) state.

```ts
/// file: src/routes/count.remote.js
Fields can be nested in objects and arrays, and their values can be strings, numbers, booleans or `File` objects. For example, if your schema looked like this...

```js
/// file: data.remote.js
import * as v from 'valibot';
import { form } from '$app/server';

export const setCount = form(
v.object({
// Valibot:
count: v.pipe(v.string(), v.transform((s) => Number(s)), v.number()),
// Zod:
// count: z.coerce.number<string>()
// ---cut---
const datingProfile = v.object({
name: v.string(),
photo: v.file(),
info: v.object({
height: v.number(),
likesDogs: v.optional(v.boolean(), false)
}),
async ({ count }) => {
// ...
}
);
attributes: v.array(v.string())
});

export const createProfile = form(datingProfile, (data) => { /* ... */ });
```

The `name` attributes on the form controls must correspond to the properties of the schema — `title` and `content` in this case. If you schema contains objects, use object notation:
...your form could look like this:

```svelte
<!--
results in a
{
name: { first: string, last: string },
jobs: Array<{ title: string, company: string }>
}
object
-->
<input name="name.first" />
<input name="name.last" />
{#each jobs as job, idx}
<input name="jobs[{idx}].title">
<input name="jobs[{idx}].company">
{/each}
<script>
import { createProfile } from './data.remote';

const { name, photo, info, attributes } = createProfile.fields;
</script>

<form {...createProfile} enctype="multipart/form-data">
<label>
<input {...name.as('text')} /> Name
</label>

<label>
<input {...photo.as('file')} /> Photo
</label>

<label>
<input {...info.height.as('number')} /> Height (cm)
</label>

<label>
<input {...info.likesDogs.as('checkbox')} /> I like dogs
</label>

<h2>My best attributes</h2>
<input {...attributes[0].as('text')} />
<input {...attributes[1].as('text')} />
<input {...attributes[2].as('text')} />

<button>submit</button>
</form>
```

To indicate a repeated field, use a `[]` suffix:
Because our form contains a `file` input, we've added an `enctype="multipart/form-data"` attribute. The values for `info.height` and `info.likesDogs` are coerced to a number and a boolean respectively.

> [!NOTE] If a `checkbox` input is unchecked, the value is not included in the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object that SvelteKit constructs the data from. As such, we have to make the value optional in our schema. In Valibot that means using `v.optional(v.boolean(), false)` instead of just `v.boolean()`, whereas in Zod it would mean using `z.coerce.boolean<boolean>()`.

In the case of `radio` and `checkbox` inputs that all belong to the same field, the `value` must be specified as a second argument to `.as(...)`:

```js
/// file: data.remote.js
import * as v from 'valibot';
import { form } from '$app/server';
// ---cut---
export const survey = form(
v.object({
operatingSystem: v.picklist(['windows', 'mac', 'linux']),
languages: v.optional(v.array(v.picklist(['html', 'css', 'js'])), [])
}),
(data) => { /* ... */ }
);
```

```svelte
<label><input type="checkbox" name="language[]" value="html" /> HTML</label>
<label><input type="checkbox" name="language[]" value="css" /> CSS</label>
<label><input type="checkbox" name="language[]" value="js" /> JS</label>
<form {...survey}>
<h2>Which operating system do you use?</h2>

{#each ['windows', 'mac', 'linux'] as os}
<label>
<input {...survey.fields.operatingSystem.as('radio', os)}>
{os}
</label>
{/each}

<h2>Which languages do you write code in?</h2>

{#each ['html', 'css', 'js'] as language}
<label>
<input {...survey.fields.languages.as('checkbox', language)}>
{language}
</label>
{/each}

<button>submit</button>
</form>
```

If you'd like type safety and autocomplete when setting `name` attributes, use the form object's `field` method:
Alternatively, you could use `select` and `select multiple`:

```svelte
<label>
<h2>Title</h2>
<input name={+++createPost.field('title')+++} />
</label>
```
<form {...survey}>
<h2>Which operating system do you use?</h2>

This will error during typechecking if `title` does not exist on your schema.
<select {...survey.fields.operatingSystem.as('select')}>
<option>windows</option>
<option>mac</option>
<option>linux</option>
</select>

The form object contains `method` and `action` properties that allow it to work without JavaScript (i.e. it submits data and reloads the page). It also has an [attachment](/docs/svelte/@attach) that progressively enhances the form when JavaScript is available, submitting data *without* reloading the entire page.
<h2>Which languages do you write code in?</h2>

<select {...survey.fields.languages.as('select multiple')}>
<option>html</option>
<option>css</option>
<option>js</option>
</select>

<button>submit</button>
</form>
```

> [!NOTE] As with unchecked `checkbox` inputs, if no selections are made then the data will be `undefined`. For this reason, the `languages` field uses `v.optional(v.array(...), [])` rather than just `v.array(...)`.

### Validation

If the submitted data doesn't pass the schema, the callback will not run. Instead, the form object's `issues` object will be populated:
If the submitted data doesn't pass the schema, the callback will not run. Instead, each invalid field's `issues()` method will return an array of `{ message: string }` objects, and the `aria-invalid` attribute (returned from `as(...)`) will be set to `true`:

```svelte
<form {...createPost}>
<label>
<h2>Title</h2>

+++ {#if createPost.issues.title}
{#each createPost.issues.title as issue}
<p class="issue">{issue.message}</p>
{/each}
{/if}+++
+++ {#each createPost.fields.title.issues() as issue}
<p class="issue">{issue.message}</p>
{/each}+++

<input
name="title"
+++aria-invalid={!!createPost.issues.title}+++
/>
<input {...createPost.fields.title.as('text')} />
</label>

<label>
<h2>Write your post</h2>

+++ {#if createPost.issues.content}
{#each createPost.issues.content as issue}
<p class="issue">{issue.message}</p>
{/each}
{/if}+++
+++ {#each createPost.fields.content.issues() as issue}
<p class="issue">{issue.message}</p>
{/each}+++

<textarea
name="content"
+++aria-invalid={!!createPost.issues.content}+++
></textarea>
<textarea {...createPost.fields.content.as('text')}></textarea>
</label>

<button>Publish!</button>
Expand All @@ -418,7 +490,7 @@ You don't need to wait until the form is submitted to validate the data — you

By default, issues will be ignored if they belong to form controls that haven't yet been interacted with. To validate _all_ inputs, call `validate({ includeUntouched: true })`.

For client-side validation, you can specify a _preflight_ schema which will populate `issues` and prevent data being sent to the server if the data doesn't validate:
For client-side validation, you can specify a _preflight_ schema which will populate `issues()` and prevent data being sent to the server if the data doesn't validate:

```svelte
<script>
Expand All @@ -440,45 +512,65 @@ For client-side validation, you can specify a _preflight_ schema which will popu

> [!NOTE] The preflight schema can be the same object as your server-side schema, if appropriate, though it won't be able to do server-side checks like 'this value already exists in the database'. Note that you cannot export a schema from a `.remote.ts` or `.remote.js` file, so the schema must either be exported from a shared module, or from a `<script module>` block in the component containing the `<form>`.

### Live inputs
To get a list of _all_ issues, rather than just those belonging to a single field, you can use the `fields.allIssues()` method:

The form object contains a `input` property which reflects its current value. As the user interacts with the form, `input` is automatically updated:
```svelte
{#each createPost.fields.allIssues() as issue}
<p>{issue.message}</p>
{/each}
```

### Getting/setting inputs

Each field has a `value()` method that reflects its current value. As the user interacts with the form, it is automatically updated:

```svelte
<form {...createPost}>
<!-- -->
</form>

<div class="preview">
<h2>{createPost.input.title}</h2>
<div>{@html render(createPost.input.content)}</div>
<h2>{createPost.fields.title.value()}</h2>
<div>{@html render(createPost.fields.content.value())}</div>
</div>
```

Alternatively, `createPost.fields.value()` would return a `{ title, content }` object.

You can update a field (or a collection of fields) via the `set(...)` method:

```svelte
<script>
import { createPost } from '../data.remote';

// this...
createPost.fields.set({
title: 'My new blog post',
content: 'Lorem ipsum dolor sit amet...'
});

// ...is equivalent to this:
createPost.fields.title.set('My new blog post');
createPost.fields.content.set('Lorem ipsum dolor sit amet');
</script>
```

### Handling sensitive data

In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) `input` is also populated if the submitted data is invalid, so that the user does not need to fill the entire form out from scratch.
In the case of a non-progressively-enhanced form submission (i.e. where JavaScript is unavailable, for whatever reason) `value()` is also populated if the submitted data is invalid, so that the user does not need to fill the entire form out from scratch.

You can prevent sensitive data (such as passwords and credit card numbers) from being sent back to the user by using a name with a leading underscore:

```svelte
<form {...register}>
<label>
Username
<input
name="username"
value={register.input.username}
aria-invalid={!!register.issues.username}
/>
<input {...register.fields.username.as('text')} />
</label>

<label>
Password
<input
type="password"
+++name="_password"+++
+++aria-invalid={!!register.issues._password}+++
/>
<input +++{...register.fields._password.as('password')}+++ />
</label>

<button>Sign up!</button>
Expand Down Expand Up @@ -680,12 +772,12 @@ This attribute exists on the `buttonProps` property of a form object:
<form {...login}>
<label>
Your username
<input name="username" />
<input {...login.fields.username.as('text')} />
</label>

<label>
Your password
<input name="password" type="password" />
<input {...login.fields._password.as('password')} />
</label>

<button>login</button>
Expand Down
Loading
Loading