Skip to content

Conversation

dummdidumm
Copy link
Member

@dummdidumm dummdidumm commented Sep 19, 2025

We just landed an enhanced version of the remote form function in SvelteKit. It makes it possible to pass a schema, have that auto-transform FormData into an object, and have properties such as input and issues for getting the form's current value or schema validation issues of a specific field.

/// my-form.remote.ts
import { form } from '$app/server';
import * as v from 'valibot';

export const createPost = form(
	v.object({
		title: v.pipe(v.string(), v.nonEmpty()),
		content:v.pipe(v.string(), v.nonEmpty())
	}),
	async ({ title, content }) => {
		// ...
	}
);

This works nicely for the simple case where you're only dealing with an object depth of 1:

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

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

		<input
			name={createPost.field('title')} // type error if title is not an actual property on the object
			aria-invalid={!!createPost.issues.title}
		/>
	</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}

		<textarea
			name={createPost.field('content')}
			aria-invalid={!!createPost.issues.content}
		></textarea>
	</label>

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

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

But as soon as you work with array leafs or nested objects, it becomes a bit cumbersome/weird:

<form {...createPost}>
	<input
		name={createPost.field('nested.array[0].property')}
		aria-invalid={!!createPost.issues['nested.array[0].property']}
	/>
	
	{#if createPost.issues['nested.array[0].property']}
		{#each createPost.issues['nested.array[0].property'] as issue}
			<p class="issue">{issue.message}</p>
		{/each}
	{/if}

	<input
		name={createPost.field('arrayLeaf[]')}
		aria-invalid={!!createPost.issues.arrayLeaf}
	/>
</form>

<div class="preview">
	<h2>{createPost.input['nested.array[0].property']}</h2>
	<h2>{createPost.input.arrayLeaf}</h2>
	<h2>{createPost.input['arrayLeaf[0]']}</h2> // this is how you would access one entry in the array
</div>

You could extract the repeated string access into a {@const ...} and reuse that but you can see it's not that pretty. It also has problems around leaf arrays which would be foo.field('array[]') for field() but foo.issues.array / foo.input.array for the others, which is kinda inconsistent and makes the {@const ...} extraction not work in all cases; the alternative of always requiring array[] looks and feels too weird.

So we kinda got thinking - could we leverage proxies instead and use regular object notation with method names at the leafs?

It would work like this:

Simple case from above:

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

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

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

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

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

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

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

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

Nested/leaf example from above:

<form {...createPost}>
	<input
		name="{createPost.fields.nested.array[0].property.name()}"
		aria-invalid={!!createPost.fields.nested.array[0].property.issues()]}
	/>
	
	{#if createPost.fields.nested.array[0].property.issues()}
		{#each createPost.fields.nested.array[0].property.issues() as issue}
			<p class="issue">{issue.message}</p>
		{/each}
	{/if}

	<input
		name={createPost.fields.arrayLeaf.name('asArray')} // subject to bikeshedding
		aria-invalid={!!createPost.fields.arrayLeaf.issues()}
	/>
</form>

<div class="preview">
	<h2>{createPostcreatePost.fields.nested.array[0].property.value()}</h2>
	<h2>{createPost.fields.arrayLeaf.value()}</h2>
	<h2>{createPost.input.arrayLeaf[0].value()}</h2> // this is how you would access one entry in the array
</div>

Again you could in both examples extract paths into {@const ...}, but this time in a consistent, safe manner.

To me this feels a lot nicer to write as I'm not munging together strings, I can also rely on autocomplete a lot better and discover the form's shape as I go instead of having to type out everything at once.

Yes, you can have a field named value/issues/name, it's possible to model this at runtime/in types

This will also offer a lot more flexibility with regards how people can build component libraries/abstractions around it.

We can also get more creative with what methods we want to have on there besides name()/issues()/value(). We could have initial()/validate()/reset()/valid() etc - basically anything that works for both objects and leaf. valid() in particular could mean "everything below me is valid", something that is really hard to pull off with the current design (you essentially would have to do something like aria-invalid={somehowGetListOfAllStrings().some(s => foo.issues[s])), with the proposed design you'd just do aria-invalid={!foo.fields.field.i.care.about.valid()}


Under the hood removes those properties in favor of a new fields property which makes interacting with the form much easier. It's using a proxy under the hood.

WIP:

  • fix .value() (did this even work in the old world?)
  • adjust docs
  • changeset

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
  • This message body should clearly illustrate what problems it solves.
  • Ideally, include a test that fails without this PR but passes with it.

Tests

  • Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running pnpm changeset and following the prompts. Changesets that add features should be minor and those that fix bugs should be patch. Please prefix changeset messages with feat:, fix:, or chore:.

Shortly after merging enhanced form validation we noticed that we can do better with respects to interacting with the form, specifically `field()/issues/input`.

This removes those properties in favor of a new `fields` property which makes interacting with the form much easier. It's using a proxy under the hood.
Copy link

changeset-bot bot commented Sep 19, 2025

🦋 Changeset detected

Latest commit: e1dc8b8

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@sveltejs/kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@svelte-docs-bot
Copy link

@Rich-Harris
Copy link
Member

us to everyone who just finished refactoring experimental version 1 to experimental version 2 after we merged #14383

image

TODO: doesn't work for nested due to Object.create(null)
@claudioluciano
Copy link

hey, i don't know if the goal is just validate the form, but i was think if the form somehow could accept a initial data, i know there's the input object but it's seems to be readonly

@leon
Copy link

leon commented Sep 23, 2025

I like this way of doing it better than relying on string for handling the nesting.

A high priority for me is how easy it is to pass along subsets of a form to components so that it is easy to write reusable components or to split a big form into multiple steps.

If each branch / leaf could have access to
value
issues
required
properties ( from jsonSchema)

creating a custom component could be very easy.
since you could have a couple of helper components for label, error.
but just pass along most of the jsonSchema properties to the input such as type, minLength,...

And as you wrote also have methods to check if the passed in form prop is valid (or any of the leafs)
you could easily show validation errors / styling higher up in the tree.

Looking forwards to see what you come up with :)

Ps. Angular seems to also be working on a new version. might find some inspiration there?
https://github.com/angular/angular/blob/prototype/signal-forms/packages/forms/signals/docs/signal-forms.md

@dummdidumm
Copy link
Member Author

Some observations while working on this:

  • .value({ someString: 'fine', someRequiredFile: 'what the hell do I put here? The types want me to put a file object in.' })
  • $derived created inside an effect not being registered on that effect bites us here, since I want to make sure we're not notifying value subtree that haven't changed

dummdidumm added a commit to sveltejs/svelte that referenced this pull request Sep 23, 2025
As part of sveltejs/kit#14481 we discovered that deriveds created within reactions and reading from them in that same reaction is actually useful in some cases, as such a use case we couldn't imagine yet in #15564 has appeared.

We think it's ultimately better to rerun on those cases, so we're going to make this change in async mode (that way the behavior doesn't change unless you have enabled the experimental flag)
dummdidumm added a commit to sveltejs/svelte that referenced this pull request Sep 23, 2025
…#16823)

* fix: depend on reads of deriveds created within reaction (async mode)

As part of sveltejs/kit#14481 we discovered that deriveds created within reactions and reading from them in that same reaction is actually useful in some cases, as such a use case we couldn't imagine yet in #15564 has appeared.

We think it's ultimately better to rerun on those cases, so we're going to make this change in async mode (that way the behavior doesn't change unless you have enabled the experimental flag)

* fix tests
@Rich-Harris
Copy link
Member

Rich-Harris commented Sep 26, 2025

This is feeling really nice. A few notes:

  • as(...) should not exist on non-leaves
  • I don't think we need a name(...) method
  • we don't have a way to distinguish between issues belonging to foo and issues belonging to foo.bar, other than the name/path of the issue (which are implementation details and should probably be redacted). Perhaps we should just have issues() on leaves, and allIssues() elsewhere
  • it looks like we currently only allow string | File as entries, not numbers and booleans
  • as a corollary I can't do .as('number') or .as('range'), at least as far as TypeScript is concerned. Ideally if an entry is a number then those should be the only options
  • no coercion occurs for numeric/checkbox inputs
  • toggling a checkbox off doesn't update value()
  • nuance around checkboxes — .as('checkbox') corresponds to a boolean, but .as('checkbox[]') must correspond to an array of strings (represented as the value attribute). But a singular checkbox input with an explicit value (not just an implicit "on" or absent) is valid... not sure if there's a way to accommodate that
  • underscored field should not be nuked after progressively enhanced submission, only when reloading the page
  • populating an array field populates all members of the array, it goes wackadoo
  • we probably need something like myform.fields.set(data) (separate methods for partial vs complete?)
  • server issues will all be deleted as soon as you type in something, we should somehow merge them with client-ones / keep some around

Will start working through these

@Rich-Harris
Copy link
Member

The maintainers had a call earlier and decided a few things:

  • Setting initial data can be done with <input {...field.as('text').initial('hello')} />
  • We'll add a new method for setting data: user.set({ name: 'rich', age: 41 }) or user.name.set('rich'). (If called during component initialization this would be an alternative to .initial(...), but we need the latter to control repeated fields)
  • We need field.select() and field.textarea() methods for those elements
  • We want to see if we can coerce <input type="date"> and datetime-local values to dates without causing timezone-related nightmares

We also talked about input masks. For now we're not going to put anything related to masks in this API, as it's the sort of thing that is probably best done with attachments

@AndreasHald
Copy link

We need field.select() and field.textarea() methods for those elements

What would these do? Or did you mean field.as('textarea')

@Rich-Harris
Copy link
Member

The preview deployment is failing because it's using the previous version of SvelteKit and the types don't match. I've verified that the docs build locally.

@Rich-Harris Rich-Harris marked this pull request as ready for review October 5, 2025 02:57
@Rich-Harris
Copy link
Member

(Side-note but I'm starting to think remote functions need their own top-level section, the docs are getting quite large)

Comment on lines 1892 to 1927
type FormField<ValueType> =
NonNullable<ValueType> extends string | string[] | number | boolean | File | File[]
? FormFieldMethods<ValueType> & {
/**
* Returns an object that can be spread onto an input element with the correct type attribute,
* aria-invalid attribute if the field is invalid, and appropriate value/checked property getters/setters.
* @example
* ```svelte
* <input {...myForm.fields.myString.as('text')} />
* <input {...myForm.fields.myNumber.as('number')} />
* <input {...myForm.fields.myBoolean.as('checkbox')} />
* ```
*/
as<T extends ValidInputTypesForValue<ValueType>>(
...args: AsArgs<T, ValueType>
): InputElementProps<T>;
}
: FormFieldMethods<ValueType> & {
/** Validation issues belonging to this or any of the fields that belong to it, if any */
allIssues(): RemoteFormIssue[] | undefined;
};

/**
* Recursive type to build form fields structure with proxy access
*/
type FormFields<T> =
WillRecurseIndefinitely<T> extends true
? RecursiveFormFields
: NonNullable<T> extends string | number | boolean | File
? FormField<T>
: T extends Array<infer U>
? FormField<T> & { [K in number]: FormFields<U> }
: FormField<T> & { [K in keyof T]-?: FormFields<T[K]> };

// By breaking this out into its own type, we avoid the TS recursion depth limit
type RecursiveFormFields = FormField<any> & { [key: string]: RecursiveFormFields };
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If these types were exported, would it be possible to create custom components that wrap individual fields?

In this simple example:

  • FormField
  • AsArgs
  • ValidInputTypesForValue
<script lang="ts">
  type ValueType = $$Generic<string | string[] | number | boolean | File | File[]>;
  type InputType = $$Generic<ValidInputTypesForValue<ValueType>>;

  interface Props extends HTMLInputAttributes {
		label: string;
    field: FormField<ValueType>;
    as: AsArgs<InputType, ValueType>;
  }

  const { label, field, as, ...rest }: Props = $props();
</script>

<label>
	<span>{label}</span>
	<input {...rest} {...field.as(...as)}>
	<!-- Errors -->
</label>
<Input field={form.fields.title} as={["text"]} />

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've renamed and exposed a couple of things and got as far as this:

<script lang="ts" generics="V extends RemoteFormFieldValue, T extends RemoteFormFieldType<V>">
	import type { RemoteFormField, RemoteFormFieldType, RemoteFormFieldValue } from '@sveltejs/kit';
	import type { HTMLInputAttributes } from 'svelte/elements';

	type ShouldHaveValue<V, T> = T extends 'radio'
		? true
		: T extends 'checkbox'
			? V extends string[]
				? true
				: false
			: false;

	type Props =
		ShouldHaveValue<V, T> extends true
			? {
					label: string;
					field: RemoteFormField<V>;
					type: T;
					value: string;
				}
			: {
					label: string;
					field: RemoteFormField<V>;
					type: T;
				};

	const {
		label,
		field,
		type,
		// @ts-expect-error
		value,
		...rest
	}: Omit<HTMLInputAttributes, 'type' | 'value'> & Props = $props();

	// @ts-expect-error
	const attributes = $derived(field.as(type, value));
</script>

<label>
	<span>{label}</span>
	<input {...rest} {...attributes} />
	<!-- Errors -->
</label>

Maybe exposing AsArgs and expecting as={["text"]} would allow us to get rid of the ts-expect-error comments it but it feels rather clunky — would be much nicer to do this:

<Input field={form.fields.title} as="text" />
<Input field={form.fields.options} as="radio" value="foo" />
<Input field={form.fields.options} as="radio" value="bar" />
<Input field={form.fields.options} as="radio" value="baz" />

Which does work with the above, with one caveat — no autocomplete for as, just red squigglies after the fact if you get it wrong. Quite involved though. Would welcome suggestions for improvements

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll see if I can improve it when I get my hands on it.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another approach could be to have separate <Radio> and <Checkbox> components, rather than shoving everything into <Input>. Might make things simpler

Copy link

@sillvva sillvva Oct 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's certainly a good approach. I copied the relevant types to a TS file and think I got autocomplete with this:

function Input<V extends RemoteFormFieldValue>(
	props: {
		label: string;
		field: RemoteFormField<V>;
	} & (V extends string[]
		? { type: "checkbox"; value: string } | { type: "select multiple" }
		: V extends string
		? { type: "radio"; value: string } | { type: Exclude<RemoteFormFieldType<V>, "radio"> }
		: { type: RemoteFormFieldType<V> }
	)
) {
	return props;
}

CleanShot 2025-10-05 at 11 01 59@2x
CleanShot 2025-10-05 at 11 02 56@2x
CleanShot 2025-10-05 at 11 03 17@2x

@saturnonearth
Copy link

saturnonearth commented Oct 5, 2025

Wow this is really cool, the proxies are a great idea - now I need this (svelte form validation) to be able to be extracted out and independently used without requiring remote fns.

Ohh also an idea for another method along with 'isValid'...etc - 'is dirty' or 'isTainted' to tell when a form input has been changed.

image

@Rich-Harris
Copy link
Member

@saturnonearth one thing at a time, please. ask us again in a month

@Rich-Harris Rich-Harris merged commit ab7df93 into main Oct 5, 2025
21 of 22 checks passed
@Rich-Harris Rich-Harris deleted the remote-form-api-tweaks branch October 5, 2025 19:04
@github-actions github-actions bot mentioned this pull request Oct 5, 2025
@Rich-Harris Rich-Harris mentioned this pull request Oct 5, 2025
6 tasks
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

8 participants