Skip to content

feat: remote form factory#14815

Open
sillvva wants to merge 28 commits into
sveltejs:mainfrom
sillvva:form-factory
Open

feat: remote form factory#14815
sillvva wants to merge 28 commits into
sveltejs:mainfrom
sillvva:form-factory

Conversation

@sillvva

@sillvva sillvva commented Oct 26, 2025

Copy link
Copy Markdown
Contributor

fixes #14802
fixes #14787

According to the docs, the result is supposed to be

... ephemeral — it will vanish if you resubmit, navigate away, or reload the page.

But that is not currently the case for navigating. The same is true of the values, issues, etc.

When you import a remote form into a page, you're importing an instance of the form. That instance is cached in memory so all properties tied to it are preserved when you navigate from page to page.

This PR changes the import into a factory function that creates a new instance each time the page/component loads, and removes the instance when it unloads.

Demo: https://stackblitz.com/edit/sveltekit-template-sjtkof-etscgbpf?file=package.json,src%2Froutes%2F%2Bpage.svelte

  <script lang="ts">
    import { todo } from "./form.remote.ts";
  </script>
  
- <form {...todo}> <!-- same instance every time -->
+ <form {...todo()}> <!-- creates a new instance -->
    ...
  </form>

And instead of .for(), you can now pass the key to the function.

<script lang="ts">
  import { todoForm, getTodos } from "./form.remote.ts";
  const todos = await getTodos();
</script>

{#each todos as todo (todo.id)}
  {@const form = todoForm(todo.id)}
  <form {...form}>
    ...
  </form>
{/each}

This also opens up the possibility of making the form configurable like this:

const data = $state(initialData);

const scopedTodo = $derived(todo({
  key: 'scoped', // key, for, or id
  preflight: schema,
  initialData: data, // $state
  /** Reset values on successful submit, for non-enhanced forms (default: true) */
  resetOnSuccess: false
}));

One thing I'll note from my testing is that while calling the function in the template like this works, there is a catch.

<form {...setData({ initialData: data })}> <!-- 4 -->
	<p>
		{#each ['a', 'b', 'c'] as item (item)}
			<label>
				<input {...setData().fields.data.as('checkbox', item)} /> <!-- 5, 6, 7 -->
				{item}
			</label>
		{/each}
	</p>
	<button type="submit">Submit Form</button>
</form>

<pre>Values: {JSON.stringify(setData().fields.value())}</pre> <!-- 1 -->
<pre>Issues: {JSON.stringify(setData().fields.allIssues())}</pre> <!-- 2 -->
<pre>Result: {JSON.stringify(setData().result)}</pre> <!-- 3 -->

The server renders this form in the order indicated by the comments. And unlike the client, it is not reactive. This causes the values in the <pre> tags to not reflect the default values passed in the <form> tag until they are rendered in the client. Meaning that you'd briefly see Values: {} before the JS runs. And in the absence of JS, that is all you'd see.

Your best bet is to call the factory function once and store the instance in a $derived() or {@const} and reuse it. If you're not passing any options to the function, then it doesn't matter.


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:.

Edits

  • Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.

@changeset-bot

changeset-bot Bot commented Oct 26, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 626f458

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
Copy Markdown

@sillvva sillvva marked this pull request as draft October 26, 2025 21:19
@sillvva sillvva marked this pull request as ready for review October 26, 2025 21:51
Comment thread documentation/docs/20-core-concepts/60-remote-functions.md Outdated
@dummdidumm

Copy link
Copy Markdown
Member

thank you! I think something like this may make sense. I haven't talked with the other maintainers yet, but maybe we could go one step further and decouple all the client-side goodies from the core which is just about posting a form to the backend. As a result you would call some function, pass the remote function in, and can also pass initial values at the same time there, etc.

Could be nice for treeshaking and more involved scenarios but could also be a bit less ergonomic in the simple case (this one, too, btw), so we'll see.

@sillvva

sillvva commented Oct 28, 2025

Copy link
Copy Markdown
Contributor Author

That also sounds good. This was just the simplest change from the existing logic, thus making the migration easier. form to form()

@munxar

munxar commented Oct 30, 2025

Copy link
Copy Markdown

@dummdidumm Regarding ergonomics: I would say that the mental model for calling a function to create the form would feel more natural to me.
I ran into a problem when I create a form with default values and navigation parameters. Currently this would result in code like:

import { dataForm, getData } from './some.remote';
const { params } = $props();
// derived needed because of params.id dependency
const data = $derived(await getData(params.id));

// how to set the init data???
// 1. this would not be reactive
dataForm.fields.set(data)

// 2. this would not run server side
$effect(() => {
    dataForm.fields.set(data)
})

// 3. set every field by hand... very unhandy and doesn't scale
<input {...dataForm.fields.text.as("text")} value={data.text} />

The function call solves this, and feels more ergonomic to me.

import { dataForm, getData } from './some.remote';
const { params } = $props();
// get the data dependent on id
const data = $derived(await getData(params.id));
// create the form dependent on data
const form = $derived(dataForm({
   initialData: data
})

Another benefit of the factory: reduce manually setting attributes on the form like multipart:

<!-- current way -->
<form {...dataForm} enctype="multipart/form-data"></form>
<script>
import { dataForm, getData } from './some.remote';
const form = dataForm({
    // simpler for the user, and could be typed
    multipart: true
})
</script>
<!-- form can now set the correct enctype -->
<form {...form}></form>

@munxar

munxar commented Oct 30, 2025

Copy link
Copy Markdown

thank you! I think something like this may make sense. I haven't talked with the other maintainers yet, but maybe we could go one step further and decouple all the client-side goodies from the core which is just about posting a form to the backend. As a result you would call some function, pass the remote function in, and can also pass initial values at the same time there, etc.

Could be nice for treeshaking and more involved scenarios but could also be a bit less ergonomic in the simple case (this one, too, btw), so we'll see.

@dummdidumm

Another benefit of a stand alone client side form model would be, that we could use it with other datasources that are not remote functions. We write tons of SPAs where SvelteKit is used with adapter-static.
If SvelteKit could give a build in interface for the forms and fields it could be used with or without remote functions
and component libraries only need a little adapter to this interface.
I start to dream... ☺️

@sillvva sillvva marked this pull request as draft November 3, 2025 15:56
@sillvva

sillvva commented Nov 3, 2025

Copy link
Copy Markdown
Contributor Author

I added some of the options I mentioned in the opening post.

export type RemoteFormFactoryOptions<Input extends RemoteFormInput | void> = {
	/** Optional key to create a scoped instance */
	key?: ExtractId<Input>;
	/** Client-side preflight schema for validation before submit */
	preflight?: StandardSchemaV1<Input, any>;
	/** Initial input values for the form fields */
	initialData?: DeepPartial<Input>;
	/** Reset the form values after successful submission (default: true) */
	resetAfterSuccess?: boolean;
};

In addition, you can write it multiple ways like this:

// No options or scope
const form = remoteForm();
// Scoped
const scoped = remoteForm('scoped');
// Configured
const configured = $derived(remoteForm({
	key: 'scoped',
	preflight: schema,
	initialData: data,
	resetAfterSuccess: false
}));

Updated Demo: https://stackblitz.com/edit/sveltekit-template-sjtkof-etscgbpf?file=package.json,src%2Froutes%2F%2Bpage.svelte

I didn't add attributes like enctype or multipart. Those should still be manually added to avoid bloating the options. My philosophy is that if it doesn't need to be an option to modify the remote form functionality, then it shouldn't be an option.

@sillvva sillvva marked this pull request as ready for review November 3, 2025 18:50
@sillvva

sillvva commented Nov 10, 2025

Copy link
Copy Markdown
Contributor Author

One thing I'll note from my testing is that while calling the function in the template like this works, there is a catch. I've added this information to the opening post too.

<form {...setData({ initialData: data })}> <!-- 4 -->
	<p>
		{#each ['a', 'b', 'c'] as item (item)}
			<label>
				<input {...setData().fields.data.as('checkbox', item)} /> <!-- 5, 6, 7 -->
				{item}
			</label>
		{/each}
	</p>
	<button type="submit">Submit Form</button>
</form>

<pre>Values: {JSON.stringify(setData().fields.value())}</pre> <!-- 1 -->
<pre>Issues: {JSON.stringify(setData().fields.allIssues())}</pre> <!-- 2 -->
<pre>Result: {JSON.stringify(setData().result)}</pre> <!-- 3 -->

The server renders this form in the order indicated by the comments. And unlike the client, it is not reactive. This causes the values in the <pre> tags to not reflect the default values passed in the <form> tag until they are rendered in the client. Meaning that you'd briefly see Values: {} before the JS runs. And in the absence of JS, that is all you'd see.

Your best bet is to call the function once and store it in a $derived() or {@const} and reuse it. If you're not passing any options to the function, then it doesn't matter.

Comment thread packages/kit/src/runtime/client/remote-functions/form.svelte.js
@mahyarmirrashed

mahyarmirrashed commented Dec 21, 2025

Copy link
Copy Markdown

Would love to see the support for initialData and resetAfterSuccess. I'm currently doing these both by hand:

updateUserSocials.fields.set({
  ...data.userSocials,
});
{...updateNotificationSettings.enhance(
  async ({ submit }) => await submit(),
)}

Being able to do

{...updateNotificationSettings({
  initialData: data.userSocials,
  resetAfterSuccess: false
})}

would be awesome.

@sillvva

sillvva commented Dec 22, 2025

Copy link
Copy Markdown
Contributor Author

In the meantime, I've created my own custom solution around the existing implementation:
https://dev.to/sillvva/sveltekit-custom-remote-form-factory-nmo

The final configureForm function provides:

  • Form instance management - Proper scoping with keys
  • Schema validation - Preflight validation before submission
  • Data initialization - Populate forms with existing data using watch helper
  • Dirty state tracking - Know when forms have been modified
  • Touched state tracking - Know when user has interacted with the form
  • Reactive updates - Respond to form instance changes using watch
  • Validation - Debounced validation with issue tracking
  • Submission handling - Comprehensive success/error handling
  • Navigation blocking - Prevent accidental data loss
  • Focus management - Auto-focus invalid fields
  • Callbacks - Flexible hooks for customization
  • Reset function - Programmatic form reset
<script>
    import { configureForm } from "$lib/factories.svelte";
    import { remoteForm } from "$lib/remote";

    let formEl: HTMLFormElement;

    const configured = configureForm(() => ({
        form: remoteForm,
        formEl,
        schema: mySchema,
        data: existingData,
        initialErrors: true,
        navBlockMessage: "You have unsaved changes. Are you sure?",
        onresult: ({ success, error }) => {
            if (success) {
                toast.success("Form saved successfully");
            } else if (error) {
                toast.error(error);
            }
        }
    }));

    const { form, attributes, dirty, submitting, touched, reset } = $derived(configured());
</script>

<form bind:this={formEl} {...attributes}>
    <!-- form fields -->
</form>

{#if dirty}
    <p>You have unsaved changes. <button onclick={reset}>Reset</button></p>
{/if}

@kevlarr

kevlarr commented Dec 23, 2025

Copy link
Copy Markdown

Would love to see the support for initialData and resetAfterSuccess. I'm currently doing these both by hand:

updateUserSocials.fields.set({
  ...data.userSocials,
});
{...updateNotificationSettings.enhance(
  async ({ submit }) => await submit(),
)}

Being able to do

{...updateNotificationSettings({
  initialData: data.userSocials,
  resetAfterSuccess: false
})}

would be awesome.

Yeah for real, same here. Adding in that enhance function just to skip resetting the form isn't that bad (even though it looks pointless without an explanatory comment or instinctive understanding why it's there) but it would be nice to avoid. Especially since it seems to lead to this bug periodically, even without using .for.

@teemingc teemingc added the forms Stuff relating to forms and form actions label Jan 6, 2026
@aarondoet

aarondoet commented Feb 7, 2026

Copy link
Copy Markdown

I'd love to see an id property in a factory which would then automatically add the id attribute to the form and the form attribute to field inputs.

Instead of

{@const myForm = createMyForm({ initialData, ... })}
<form {...myForm} id="myId">...</form>
<input {...myForm.fields.myField.as("text")} form="myId" />

one could just use

{@const myForm = createMyForm({ initialData, id: "myForm", ... })}
<form {...myForm}>...</form>
<input {...myForm.fields.myField.as("text")} />

maybe even a <button {...myForm.submit}> or similar, that is { form: "myForm", type: "submit" }

While I try avoiding the need for this, sadly forms and tables don't work together well and I can't just put the inputs in the form itself sometimes. At first I thought myForm.for(...) would do that and I was kinda sad to find out that it does not.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

forms Stuff relating to forms and form actions

Projects

None yet

9 participants