Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
01a4071
feat: remote form factory
sillvva Oct 26, 2025
e9f0fe4
Merge branch 'sveltejs:main' into form-factory
sillvva Oct 26, 2025
de70ffe
simplify types
sillvva Oct 26, 2025
13ebc2c
omit type from instance
sillvva Oct 26, 2025
112b692
fix types
sillvva Oct 26, 2025
49deca3
remove unused type
sillvva Oct 26, 2025
63b2af1
fix form tests
sillvva Oct 26, 2025
32f923e
fix
sillvva Oct 26, 2025
f9c1d46
fix instance caching for multiple factory invocations with the same key
sillvva Oct 26, 2025
aa3f8e1
remove console.log
sillvva Oct 27, 2025
ec5cb13
Merge branch 'main' into form-factory
sillvva Oct 28, 2025
e6ebca5
Add remote form factory options
sillvva Nov 3, 2025
05cca10
remove preflight method in favor of option, simplify factory
sillvva Nov 3, 2025
2a69677
clarify remote form type descriptions
sillvva Nov 3, 2025
dcac882
optimize form instance caching + reactivity fix
sillvva Nov 8, 2025
f37acfe
Merge branch 'main' into form-factory
sillvva Nov 8, 2025
77148c9
reverse server instance caching
sillvva Nov 8, 2025
ddd0f89
improves form instance key uniqueness
sillvva Nov 9, 2025
f4c69c7
Merge branch 'main' into form-factory
sillvva Nov 20, 2025
2b6901b
fix: add type assertion for initialData in form function
sillvva Nov 20, 2025
799be57
format
sillvva Nov 20, 2025
d6d4236
Merge branch 'main' into form-factory
sillvva Nov 20, 2025
3db7f4c
Merge branch 'main' into form-factory
sillvva Nov 20, 2025
6ebd606
fix instance handler
sillvva Nov 20, 2025
b914a92
fix test
sillvva Nov 20, 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/all-symbols-hammer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: remote form factory
105 changes: 65 additions & 40 deletions documentation/docs/20-core-concepts/60-remote-functions.md
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,12 @@ export const createPost = form(
<!--- file: src/routes/blog/new/+page.svelte --->
<script>
import { createPost } from '../data.remote';
const form = createPost();
</script>

<h1>Create a new post</h1>

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

<button>Publish!</button>
Expand All @@ -308,15 +309,20 @@ As with `query`, if the callback uses the submitted `data`, it should be [valida
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}>
<script>
import { createPost } from '../data.remote';
const form = createPost();
</script>

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

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

<button>Publish!</button>
Expand Down Expand Up @@ -351,10 +357,11 @@ export const createProfile = form(datingProfile, (data) => { /* ... */ });
<script>
import { createProfile } from './data.remote';

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

<form {...createProfile} enctype="multipart/form-data">
<form {...form} enctype="multipart/form-data">
<label>
<input {...name.as('text')} /> Name
</label>
Expand Down Expand Up @@ -401,12 +408,17 @@ export const survey = form(
```

```svelte
<form {...survey}>
<script>
import { survey } from '../data.remote';
const form = survey();
</script>

<form {...form}>
<h2>Which operating system do you use?</h2>

{#each ['windows', 'mac', 'linux'] as os}
<label>
<input {...survey.fields.operatingSystem.as('radio', os)}>
<input {...form.fields.operatingSystem.as('radio', os)}>
{os}
</label>
{/each}
Expand All @@ -415,7 +427,7 @@ export const survey = form(

{#each ['html', 'css', 'js'] as language}
<label>
<input {...survey.fields.languages.as('checkbox', language)}>
<input {...form.fields.languages.as('checkbox', language)}>
{language}
</label>
{/each}
Expand All @@ -427,18 +439,18 @@ export const survey = form(
Alternatively, you could use `select` and `select multiple`:

```svelte
<form {...survey}>
<form {...form}>
<h2>Which operating system do you use?</h2>

<select {...survey.fields.operatingSystem.as('select')}>
<select {...form.fields.operatingSystem.as('select')}>
<option>windows</option>
<option>mac</option>
<option>linux</option>
</select>

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

<select {...survey.fields.languages.as('select multiple')}>
<select {...form.fields.languages.as('select multiple')}>
<option>html</option>
<option>css</option>
<option>js</option>
Expand Down Expand Up @@ -492,25 +504,30 @@ The `invalid` function works as both a function and a proxy:
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}>
<script>
import { createPost } from '../data.remote';
const form = createPost();
</script>

<form {...form}>
<label>
<h2>Title</h2>

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

<input {...createPost.fields.title.as('text')} />
<input {...form.fields.title.as('text')} />
</label>

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

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

<textarea {...createPost.fields.content.as('text')}></textarea>
<textarea {...form.fields.content.as('text')}></textarea>
</label>

<button>Publish!</button>
Expand All @@ -520,7 +537,7 @@ If the submitted data doesn't pass the schema, the callback will not run. Instea
You don't need to wait until the form is submitted to validate the data — you can call `validate()` programmatically, for example in an `oninput` callback (which will validate the data on every keystroke) or an `onchange` callback:

```svelte
<form {...createPost} oninput={() => createPost.validate()}>
<form {...form} oninput={() => form.validate()}>
<!-- -->
</form>
```
Expand All @@ -534,6 +551,8 @@ For client-side validation, you can specify a _preflight_ schema which will popu
import * as v from 'valibot';
import { createPost } from '../data.remote';

const form = createPost();

const schema = v.object({
title: v.pipe(v.string(), v.nonEmpty()),
content: v.pipe(v.string(), v.nonEmpty())
Expand All @@ -542,7 +561,7 @@ For client-side validation, you can specify a _preflight_ schema which will popu

<h1>Create a new post</h1>

<form {...+++createPost.preflight(schema)+++}>
<form {...+++form.preflight(schema)+++}>
<!-- -->
</form>
```
Expand All @@ -552,7 +571,7 @@ For client-side validation, you can specify a _preflight_ schema which will popu
To get a list of _all_ issues, rather than just those belonging to a single field, you can use the `fields.allIssues()` method:

```svelte
{#each createPost.fields.allIssues() as issue}
{#each form.fields.allIssues() as issue}
<p>{issue.message}</p>
{/each}
```
Expand All @@ -562,33 +581,35 @@ To get a list of _all_ issues, rather than just those belonging to a single fiel
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 {...form}>
<!-- -->
</form>

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

Alternatively, `createPost.fields.value()` would return a `{ title, content }` object.
Alternatively, `form.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';

const form = createPost();

// this...
createPost.fields.set({
form.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');
form.fields.title.set('My new blog post');
form.fields.content.set('Lorem ipsum dolor sit amet');
</script>
```

Expand All @@ -599,15 +620,15 @@ In the case of a non-progressively-enhanced form submission (i.e. where JavaScri
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}>
<form {...form}>
<label>
Username
<input {...register.fields.username.as('text')} />
<input {...form.fields.username.as('text')} />
</label>

<label>
Password
<input +++{...register.fields._password.as('password')}+++ />
<input +++{...form.fields._password.as('password')}+++ />
</label>

<button>Sign up!</button>
Expand Down Expand Up @@ -666,7 +687,7 @@ The second is to drive the single-flight mutation from the client, which we'll s

### Returns and redirects

The example above uses [`redirect(...)`](@sveltejs-kit#redirect), which sends the user to the newly created page. Alternatively, the callback could return data, in which case it would be available as `createPost.result`:
The example above uses [`redirect(...)`](@sveltejs-kit#redirect), which sends the user to the newly created page. Alternatively, the callback could return data, in which case it would be available as `form.result`:

```ts
/// file: src/routes/blog/data.remote.js
Expand Down Expand Up @@ -711,15 +732,16 @@ export const createPost = form(
<!--- file: src/routes/blog/new/+page.svelte --->
<script>
import { createPost } from '../data.remote';
const form = createPost();
</script>

<h1>Create a new post</h1>

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

{#if createPost.result?.success}
{#if form.result?.success}
<p>Successfully published!</p>
{/if}
```
Expand All @@ -739,11 +761,12 @@ We can customize what happens when the form is submitted with the `enhance` meth
<script>
import { createPost } from '../data.remote';
import { showToast } from '$lib/toast';
const form = createPost();
</script>

<h1>Create a new post</h1>

<form {...createPost.enhance(async ({ form, data, submit }) => {
<form {...form.enhance(async ({ form, data, submit }) => {
try {
await submit();
form.reset();
Expand Down Expand Up @@ -796,7 +819,7 @@ The override will be applied immediately, and released when the submission compl

### Multiple instances of a form

Some forms may be repeated as part of a list. In this case you can create separate instances of a form function via `for(id)` to achieve isolation.
Some forms may be repeated as part of a list. In this case you can create separate instances of a form function via `form(id)` to achieve isolation.

```svelte
<!--- file: src/routes/todos/+page.svelte --->
Expand All @@ -807,7 +830,7 @@ Some forms may be repeated as part of a list. In this case you can create separa
<h1>Todos</h1>

{#each await getTodos() as todo}
{@const modify = modifyTodo.for(todo.id)}
{@const modify = modifyTodo(todo.id)}
<form {...modify}>
<!-- -->
<button disabled={!!modify.pending}>save changes</button>
Expand All @@ -827,21 +850,23 @@ This attribute exists on the `buttonProps` property of a form object:
<!--- file: src/routes/login/+page.svelte --->
<script>
import { login, register } from '$lib/auth';
const loginForm = login();
const registerForm = register();
</script>

<form {...login}>
<form {...loginForm}>
<label>
Your username
<input {...login.fields.username.as('text')} />
<input {...loginForm.fields.username.as('text')} />
</label>

<label>
Your password
<input {...login.fields._password.as('password')} />
<input {...loginForm.fields._password.as('password')} />
</label>

<button>login</button>
<button {...register.buttonProps}>register</button>
<button {...registerForm.buttonProps}>register</button>
</form>
```

Expand Down
19 changes: 4 additions & 15 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2003,6 +2003,10 @@ type InvalidField<T> =
export type Invalid<Input = any> = ((...issues: Array<string | StandardSchemaV1.Issue>) => never) &
InvalidField<Input>;

export type RemoteFormFactory<Input extends RemoteFormInput | void, Output> = (
key?: ExtractId<Input>
) => RemoteForm<Input, Output>;

/**
* The return value of a remote `form` function. See [Remote functions](https://svelte.dev/docs/kit/remote-functions#form) for full documentation.
*/
Expand All @@ -2026,21 +2030,6 @@ export type RemoteForm<Input extends RemoteFormInput | void, Output> = {
action: string;
[attachment: symbol]: (node: HTMLFormElement) => void;
};
/**
* Create an instance of the form for the given `id`.
* The `id` is stringified and used for deduplication to potentially reuse existing instances.
* Useful when you have multiple forms that use the same remote form action, for example in a loop.
* ```svelte
* {#each todos as todo}
* {@const todoForm = updateTodo.for(todo.id)}
* <form {...todoForm}>
* {#if todoForm.result?.invalid}<p>Invalid data</p>{/if}
* ...
* </form>
* {/each}
* ```
*/
for(id: ExtractId<Input>): Omit<RemoteForm<Input, Output>, 'for'>;
/** Preflight checks */
preflight(schema: StandardSchemaV1<Input, any>): RemoteForm<Input, Output>;
/** Validate the form contents programmatically */
Expand Down
Loading
Loading