kit icon indicating copy to clipboard operation
kit copied to clipboard

feat: remote form factory

Open sillvva opened this issue 2 months ago • 8 comments

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:

  • [x] 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
  • [x] This message body should clearly illustrate what problems it solves.
  • [x] Ideally, include a test that fails without this PR but passes with it.

Tests

  • [x] Run the tests with pnpm test and lint the project with pnpm lint and pnpm check

Changesets

  • [x] 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

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

sillvva avatar Oct 26 '25 21:10 sillvva

🦋 Changeset detected

Latest commit: 626f45825e36f78578d929d75d18dceaa9c4cf32

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

changeset-bot[bot] avatar Oct 26 '25 21:10 changeset-bot[bot]

Preview: https://svelte-dev-git-preview-kit-14815-svelte.vercel.app/

svelte-docs-bot[bot] avatar Oct 26 '25 21:10 svelte-docs-bot[bot]

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 avatar Oct 28 '25 21:10 dummdidumm

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

sillvva avatar Oct 28 '25 21:10 sillvva

@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 avatar Oct 30 '25 05:10 munxar

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... ☺️

munxar avatar Oct 30 '25 08:10 munxar

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 avatar Nov 03 '25 16:11 sillvva

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.

sillvva avatar Nov 10 '25 02:11 sillvva

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.

mahyarmirrashed avatar Dec 21 '25 05:12 mahyarmirrashed

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}

sillvva avatar Dec 22 '25 18:12 sillvva