Svelte-Command-Form allows you to have easy to use forms with commands instead of remote forms. Is this redundant? Maybe. However, you may not want to use an HTML form everytime. The API is greatly influenced by SvelteKit-Superforms, so if you are used to that you shouldn't have a problem here.
Whenever possible you should use the SvelteKit provided form remote function since commands will fail in non-JS environments, but there may be cases where that is not practical or you just like the ease of interacting with an object instead of form data.
-
Schema-agnostic validation – Works with any library that implements the Standard Schema V1 interface. If you are unsure if your schema validation library is compatible see the list of compatible libraries.
-
Command-first workflow – Wire forms directly to your remote command (
commandfrom$app/server), and let the helper manage submission, success, and error hooks. -
Typed form state –
form,errors, andissuesare all strongly typed from your schema, so your component code stays in sync with validation rules. -
Friendly + raw errors – Surface user-friendly
errorsfor rendering, while also exposing the untouched validatorissuesarray for logging/analytics. -
Helpers for remote inputs – Includes
normalizeFilesfor bundling file uploads andstandardValidatefor reusing schema validation outside the form class.Standard validate was yoinked straight from the
StandardSchemaGitHub
pnpm add @akcodeworks/svelte-command-form
# or
npm install @akcodeworks/svelte-command-form<script lang="ts">
import { CommandForm } from '@akcodeworks/svelte-command-form';
import { schema } from '$lib/schemas/user.schema';
import { saveUser } from '$lib/server/save-user';
const form = new CommandForm(schema, {
initial: { name: '' },
command: saveUser,
reset: 'onSuccess',
onSuccess: (result) => console.log('Saved', result)
});
</script>
<input bind:value="{form.form.name}" placeholder="Name" />
{#if form.errors.name}
<p class="error">{form.errors.name.message}</p>
{/if}
<button disabled="{form.submitting}" onclick="{form.submit}">
{form.submitting ? 'Saving…' : 'Save'}
</button>CommandForm keeps two synchronized error stores:
errors– per-field objects{ message: string }that are easy to render.issues– the untouched Standard Schema issue array (useful for logs/analytics).
To display errors in the DOM, check the keyed entry in form.errors:
<label>
Name
<input bind:value="{form.form.name}" />
</label>
{#if form.errors.name}
<p class="error">{form.errors.name.message}</p>
{/if}
<label>
Age
<input type="number" bind:value="{form.form.age}" />
</label>
{#if form.errors.age}
<p class="error">{form.errors.age.message}</p>
{/if}Running await form.validate() triggers the same schema parsing as submit() without sending data, so you can eagerly show validation feedback (e.g., on blur). Whenever validation passes, both errors and issues are cleared.
Array and nested errors follow the dot-path reported by your schema. If the schema declares names: z.array(z.string()) and the user submits [123], the error map becomes:
{
'names.0': { message: 'Expected string' }
}Render that however makes sense—either surface the aggregated message near the group (form.errors['names.0']?.message) or group entries by prefix to display per-item errors.
Any schema object that exposes the ~standard property works:
import { z } from 'zod'; // or create a schema with any StandardSchemaV1 compliant lib.
const userSchema = z.object({
name: z.string().min(2),
email: z.string().email()
});
const form = new CommandForm(userSchema, { command: saveUser });The schema that the command accepts.
// someCommand.schema.ts
import { z } from 'zod';
const schema = z.object({
name: z.string().min(1, 'Must have a name')
});
export { schema as someCommandSchema };<script lang="ts">
import { someCommandSchema } from '$lib/someCommand.schema.ts';
const cmd = new CommandForm(someCommandSchema, {
// ... other options
});
</script>Optional initial values. Returning a functions lets you compute defaults per form instance and/or when computed values change, like when using $derived()
You must set default values here if you are using them, default values are not able to be extracted from a
StandardSchemaV1
Example:
<script lang="ts">
let { data } = $props();
let { name } = $derived(data);
const cmd = new CommandForm(schema, {
// if you do not use a function to get the value of name here
// you will never get the updated value
initial: () => ({
name
})
// ...other options
});
</script>
<input bind:value="{form.name}" />
<button onclick="{cmd.form.submit}">Change Name</button>The command function that is being called.
Example:
<script lang="ts">
import someCommand from '$lib/remote/some-command.remote';
const cmd = new CommandForm(schema, {
command: someCommand
// ...other options
});
</script>Optional SvelteKit invalidation targets. Can be set to a single string, a string[] for multiple targets, or a literal of all to run invalidateAll()
This only runs on successful form submissions
Example:
<script lang="ts">
const cmd = new CommandForm(schema, {
invalidate: 'user:details' // invalidates routes with depends("user:details") set
// ...other options
});
</script>Allows you to select if the form should be reset. By default, the form never resets. This accepts a value of onSuccess | onError or always
Example:
<script lang="ts">
const cmd = new CommandForm(schema, {
reset: 'always' // the form will reset after submission no matter what
// ...other options
});
</script>Allows you to preprocess any data you have set when the form is submitted. This will run prior to any parsing on the client. For example if you would need to convert an input of type 'date' to an ISO string on the client before submitting. If this is a promise, it will be awaited before continuing.
Preprocessed data creates a
$state.snapshot()of your form data. Thus it is ephemeral, that way if the command fails, your form data is not already processed.
<script lang="ts">
const cmd = new CommandForm(schema, {
preprocess: (data) => {
return { ...data, name: data.name.trim() };
}
// ... other options
});
</script>
<input type="date" bind:value="{cmd.form.someDate}" />Runs when the form is submitted. *The data available inside of this function is the result of your preprocess function if you have one. This can also be a promise.
<script lang="ts">
const cmd = new CommandForm(schema, {
onSubmit: (data) => {
toast.loading('Submitting data...please wait!');
}
// ... other options
});
</script>
<input type="date" bind:value="{cmd.form.someDate}" />Runs if the form is submitted and returns sucessfully. You will have access to the returned value from the command that is ran. This can also be a promise.
<script lang="ts">
const cmd = new CommandForm(schema, {
onSuccess: (response) => {
toast.success(`${response.name} has been updated!`);
}
// ... other options
});
</script>
<input type="date" bind:value="{cmd.form.someDate}" />Runs if the command fails and an error is returned.
<script lang="ts">
const cmd = new CommandForm(schema, {
onError: (error) => {
toast.error('Oops! Something went wrong!');
console.error(error);
}
// ... other options
});
</script>When you create a new CommandForm you get access to several methods and values that will help you manage your form state, submit, reset, and/or display errors.
In the following examples we will be using the following command form.
<script lang="ts">
const cmd = new CommandForm(schema, {
initial: {
name: 'Ada Lovelace',
age: '30'
}
});
</script>Gives you access to the data within the form. Useful when binding to inputs.
<input placeholder="What is your name?" bind:value={cmd.form.name} />Allows you to programatically merge form field values in bulk or add other values. If you set clear to true, it will replace all values instead of merging them in.
set({ name: 'Linus Torvalds' });
// cmd.form will now be {name: "Linus Torvalds", age: 30}
set({ name: 'Linus Sebastian' }, true);
// cmd.form will now be {name: "Linus Sebastian"}Resets the form to the initial values that were passed in when it was instantiated.
Note: If you are using an accessor function inside of
options.initialit will reset to the newest available value instead of what it was when you instantiated it.
Runs the parser and populates any errors. Useful if you want to display errors in realtime as the user is filling out the form. It will also clear any errors as they are corrected each time it is run.
If you are using
options.preprocessthis is not ran duringvalidate()however if you are using a schema library preprocessor such aszod.preprocessit should be ran within the parse.
<input bind:value={cmd.form.name} onchange={cmd.validate} />
{#if cmd.errors.name}
<!-- display the error -->
{#if}Returns a boolean indicatiing whether the form is in flight or not. Useful for setting disabled states or showing loading spinners while the data is processed.
{#if cmd.submitting}
Please wait while we update your name...
{:else}
<input bind:value={cmd.form.name} />
{/if}
<button onclick={cmd.submit} disabled={cmd.submitting}>Submit</button>Returns back an easily accessible object with any validation errors. See Errors for more information on how to render.
Returns back the raw validation issues. See Issues for more information.
SvelteKit command functions currently expect JSON-serializable payloads, so File objects cannot be passed directly from
the client to a command.
Use the provided normalizeFiles helper to convert browser
File instances into serializable blobs inside the onSubmit hook (so the parsed
data that reaches your command already contains normalized entries):
<script lang="ts">
import { CommandForm, normalizeFiles } from '@akcodeworks/svelte-command-form';
import { zodSchema } from '$lib/schemas/upload.schema';
import { uploadCommand } from '$lib/server/upload.remote';
const cmd = new CommandForm(zodSchema, {
command: uploadCommand,
async preprocess(data) {
cmd.form.attachments = await normalizeFiles(data.attachments);
}
});
const handleFiles = (event: Event) => {
const input = event.target as HTMLInputElement;
form.set({ attachments: input.files ? [...input.files] : [] });
};
</script>
<input type="file" multiple onchange="{handleFiles}" />normalizeFiles outputs objects like:
type NormalizedFile = {
name: string;
type: string;
size: number;
bytes: Uint8Array;
};Both the Zod and Valibot schemas above can be adapted to accept either File[] (for client-side validation) or this normalized structure if you prefer validating the serialized payload on the server.
When validation fails, CommandForm:
- Throws/catches
SchemaValidationErrorfromstandardValidate. - Converts issues into
errors(per field) viatransformIssues. - Stores the raw issue array in
issuesfor programmatic access.
If you need to manually set an error follow these steps.
- Setup your hooks.server.ts file and add an error handler.
export const handleError: HandleServerError = async ({ error }) => {
// Note: If you don't want bad actors seeing your validation issues, you can do an auth check here before returning
if (error instanceof SchemaValidationError) return error as SchemaValidationError;
};- Throw a new
SchemaValidationErrorinside of the command.
export const test = command(schema, async (data) => {
const user = await db.user.findFirst({ where: { email: data.email } });
if (!user)
throw new SchemaValidationError([
{ path: ['email'], message: 'Name is invalid server error!!' }
]);
});In the above example this will populate the form.errors.email.message field so you can display the error to the user on the client.
If you do not add the custom error handler in step 1, you will not get any issues back. This is a SvelteKit design principle when dealing with Remote Functions!
You can add errors manually by using the addErrors method (client only) or by throwing a new SchemaValidationError.
// server add error
const someFunc = command(schema, async (data) => {
const user = await db.find({where: email: data.email})
if(!user) throw new SchemaValidationError([{ message: "User does with this email does not exist!", path: ['email'] }])
})<!-- +page.svelte -->
<script lang="ts">
const form = new CommandForm(schema, {
command: someCommand
});
function addError() {
form.addError({ path: 'name', message: 'Test Error' });
}
</script>
<button onclick="{addError}">Add an Error</button>addError() does NOT throw an error, you will have to do that once you call it. If you want to throw an error, throw a new
SchemaValidationError
Feel free to contribute by opening a PR with a detailed description of why you are wanting to change what you are changing. If it can be tested with Vitest, that is preferred.