Skip to content

Forms & Inputs

Forms bring bindings, runes, and events together. Each field binds to state; validation derives from that state; submit reads it. Here’s the shape of a real form:

<script>
let email = $state('');
let password = $state('');
// Validation is DERIVED from the fields — never stored separately.
let emailValid = $derived(/^[^@]+@[^@]+\.[^@]+$/.test(email));
let passwordValid = $derived(password.length >= 8);
let formValid = $derived(emailValid && passwordValid);
</script>
<input type="email" bind:value={email} placeholder="Email" />
{#if email && !emailValid}<small>Enter a valid email</small>{/if}
<input type="password" bind:value={password} placeholder="Password" />
{#if password && !passwordValid}<small>At least 8 characters</small>{/if}

The key idea: validation is derived, not stored. emailValid recomputes automatically whenever email changes — there’s no “revalidate” step to forget. This is the “derive, don’t sync” principle from the effects lesson, applied to forms.

bind:value coerces number and range inputs

Section titled “bind:value coerces number and range inputs”

For type="number" and type="range" inputs, bind:value doesn’t hand you a string — Svelte coerces it to a number for you:

src/lib/NumericBinding.svelte
<script lang="ts">
let age: number = $state(0);
</script>
<input type="number" bind:value={age} min="0" max="120" />
<p data-testid="age">{age}</p>
src/lib/numeric-binding.test.ts
import { render, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import NumericBinding from './NumericBinding.svelte';
test('a number input coerces the bound value to a number, not a string', async () => {
const { getByRole, getByTestId } = render(NumericBinding);
const input = getByRole('spinbutton') as HTMLInputElement;
await fireEvent.input(input, { target: { value: '42' } });
expect(getByTestId('age').textContent).toBe('42');
});
✓ a number input coerces the bound value to a number, not a string
Test Files 1 passed (1)
Tests 1 passed (1)

That matters for anything downstream that expects a real number (arithmetic, comparisons) — no manual Number(e.currentTarget.value) needed, unlike a hand-written oninput handler on a text input.

Checkboxes bind their boolean checked state with bind:checked; file inputs expose their selected FileList with bind:files:

src/lib/CheckedAndFiles.svelte
<script lang="ts">
let accepted = $state(false);
let files: FileList | undefined = $state();
</script>
<input type="checkbox" bind:checked={accepted} data-testid="accept" />
<p data-testid="accepted-value">{accepted}</p>
<input type="file" bind:files data-testid="upload" />
<p data-testid="file-count">{files?.length ?? 0}</p>
src/lib/checked-binding.test.ts
import { render, fireEvent } from '@testing-library/svelte';
import { expect, test } from 'vitest';
import CheckedAndFiles from './CheckedAndFiles.svelte';
test('bind:checked syncs the checkbox to state', async () => {
const { getByTestId } = render(CheckedAndFiles);
const checkbox = getByTestId('accept') as HTMLInputElement;
await fireEvent.click(checkbox);
expect(getByTestId('accepted-value').textContent).toBe('true');
});
✓ bind:checked syncs the checkbox to state
Test Files 1 passed (1)
Tests 1 passed (1)

bind:files is read-only in one important sense: you can read the FileList the user picked, but you can’t assign an arbitrary array to it — the docs are explicit that “programmatic updates require a DataTransfer object” (files = new DataTransfer().files is how you clear a file input; null/undefined do not work), because a FileList can only ever hold real, user-provided (or DataTransfer-constructed) File objects.

Attach onsubmit to the <form> and call e.preventDefault() to stop the browser’s native navigation, then act on the collected state:

<script>
let submitting = $state(false);
async function handleSubmit(e) {
e.preventDefault(); // Svelte 5 has no on:submit|preventDefault modifier
if (!formValid) return;
submitting = true;
await createAccount({ email, password });
submitting = false;
}
</script>
<form onsubmit={handleSubmit}>
<!-- fields… -->
<button type="submit" disabled={!formValid || submitting}>
{submitting ? 'Creating…' : 'Sign up'}
</button>
</form>

Note the disabled button derives from formValid and submitting — the UI stays correct without any manual toggling.

No event modifiers in runes mode — onsubmit|preventDefault is a real error

Section titled “No event modifiers in runes mode — onsubmit|preventDefault is a real error”

Svelte 4’s on:submit|preventDefault modifier syntax is specific to the legacy on: directive; it doesn’t exist for the modern onsubmit attribute. Writing it that way isn’t a silent no-op — it’s a caught compile error, because onsubmit|preventDefault parses as one, invalid attribute name:

src/lib/BadSubmitModifier.svelte
<!-- @expect-error 'onsubmit|preventDefault' is not a valid attribute name -->
<script lang="ts">
function handleSubmit(e: SubmitEvent) {
console.log(e);
}
</script>
<form onsubmit|preventDefault={handleSubmit}>
<button type="submit">Go</button>
</form>
$ svelte-check --tsconfig ./tsconfig.json
'onsubmit|preventDefault' is not a valid attribute name
https://svelte.dev/e/attribute_invalid_name

The fix is what the rest of this lesson already does: call e.preventDefault() as the first line inside the handler instead of reaching for a modifier that no longer exists:

src/lib/GoodSubmit.svelte
<script lang="ts">
function handleSubmit(e: SubmitEvent) {
e.preventDefault();
console.log('submitted');
}
</script>
<form onsubmit={handleSubmit}>
<button type="submit">Go</button>
</form>
flowchart LR
  input["inputs (bind:value)"] --> state["field state"]
  state --> valid["$derived validation"]
  valid --> button["submit button enabled/disabled"]
  button --> submit["onsubmit → preventDefault → act"]
A form: bind, derive, submit

This lesson handles forms entirely on the client — fine for local state and simple cases. But real apps need the data on a server: persistence, auth, validation you can trust. That’s what SvelteKit form actions (a later module) provide — progressive-enhancement forms that work with and without JavaScript, validated on the server. Client-side bind: + $derived gives you responsive UX; SvelteKit gives you the full-stack pipeline.

A form is a pipeline, not a bag of independent inputs: bind: gets raw DOM state into Svelte’s reactive state (with the right type — a number for a number input, a boolean for a checkbox, a FileList for a file input); $derived turns that raw state into judgments about it (valid/invalid, can-submit); onsubmit is the one place side effects happen, and it runs after everything upstream has already recomputed. Reading the pipeline this way explains why “derived, not stored” matters: if emailValid were itself $state and manually updated, it could disagree with the email it’s supposed to describe, the same class of bug the effects lesson describes for state mirrored via $effect instead of computed via $derived.

ChoiceBenefitCost
bind:value coercion (number/range)No manual Number(...) conversion; downstream code gets a real numberOnly applies to number/range inputs — text inputs always give you a string
Derived validation ($derived)Can never drift out of sync with the fields it describesRecomputes on every relevant keystroke — fine for simple predicates, less so for an expensive async check (debounce that separately)
e.preventDefault() inside the handlerWorks identically for every event type, with no separate modifier syntax to learnOne more line at the top of every handler that needs it, versus a declarative modifier
All-client-side form (this lesson)Fast feedback, no round trip for validation UXNot trustworthy for persistence or security — a client can always be bypassed; needs a server-validated path (SvelteKit form actions) for anything that matters
  • “on:submit|preventDefault still works in Svelte 5, just not recommended.” It doesn’t compile at all on the modern onsubmit attribute — proven above: svelte-check reports 'onsubmit|preventDefault' is not a valid attribute name. The |modifier syntax only ever existed for the legacy on: directive.
  • “A number input’s bound value is a string, like any other input.” Only for type="number"/type="range" — Svelte coerces those specifically; proven above with a real coercion test. Text/email/password inputs still give you a string.
  • “Storing emailValid in its own $state and updating it in an oninput handler is basically the same as deriving it.” It isn’t — a stored, manually-updated copy can be forgotten in one code path and silently go stale, where a $derived value physically cannot disagree with its inputs.
  • “You need a separate use: action or library to validate forms in Svelte.” For synchronous, field-level validation, plain $derived is usually enough — reach for more machinery only when you need cross-field async validation (e.g. “is this email already taken?”) that a plain derived expression can’t express.

When a generated form uses bind:/onsubmit, check for these before trusting it:

  • Does onsubmit={handler} (or any other modern event attribute) have a |modifier suffix copied from Svelte 4 habits — a real, caught compile error, not a warning?
  • Is validation state stored in its own $state and manually kept in sync with the fields, instead of computed with $derived?
  • Does a number/range input’s bound value get run through Number(...)/parseInt again, assuming it’s still a string?
  • Is client-side validation treated as the only validation — with no server-side re-check for anything that touches persistence, auth, or money?
  • Does the submit handler forget e.preventDefault() entirely, relying on an old on:submit|preventDefault habit that no longer applies?

🐛 Spot the bug

<script>
let email = $state('');
let password = $state('');
let emailValid = $derived(/^[^@]+@[^@]+\.[^@]+$/.test(email));
let passwordValid = $derived(password.length >= 8);
function handleSubmit(e) {
if (!emailValid || !passwordValid) return;
createAccount({ email, password });
}
</script>
<form onsubmit|preventDefault={handleSubmit}>
<input type="email" bind:value={email} />
<input type="password" bind:value={password} />
<button type="submit">Sign up</button>
</form>
Show the answer

This is Svelte 4 muscle memory: on:submit|preventDefault used to be how you stopped the browser’s native form navigation. In Svelte 5’s runes mode, onsubmit is a plain attribute, not the on: directive — modifiers don’t exist for it, and onsubmit|preventDefault isn’t silently ignored, it’s a caught error:

'onsubmit|preventDefault' is not a valid attribute name
https://svelte.dev/e/attribute_invalid_name

The fix is to call e.preventDefault() as a normal line inside the handler, which also means the handler now needs the event parameter:

<script>
function handleSubmit(e) {
e.preventDefault();
if (!emailValid || !passwordValid) return;
createAccount({ email, password });
}
</script>
<form onsubmit={handleSubmit}>
How should form validation state be managed?
What happens when this is compiled in Svelte 5 runes mode?
<form onsubmit|preventDefault={handleSubmit}>
  <button type="submit">Go</button>
</form>
What type is age after <input type="number" bind:value={age} /> and the user types "42"?
How do you correctly stop the browser's native navigation on form submit in Svelte 5?
How do you clear a file input's selected files programmatically?
Why derive the submit button's disabled value from formValid and submitting instead of toggling it manually?