feat: remote form factory
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 testand lint the project withpnpm lintandpnpm 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 changesetand following the prompts. Changesets that add features should beminorand those that fix bugs should bepatch. Please prefix changeset messages withfeat:,fix:, orchore:.
Edits
- [x] Please ensure that 'Allow edits from maintainers' is checked. PRs without this option may be closed.
🦋 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
Preview: https://svelte-dev-git-preview-kit-14815-svelte.vercel.app/
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.
That also sounds good. This was just the simplest change from the existing logic, thus making the migration easier. form to form()
@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>
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... ☺️
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.
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.
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.
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}