Forms & Inputs
Controlled inputs with bind:
Section titled “Controlled inputs with bind:”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:
<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>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.
bind:checked and bind:files
Section titled “bind:checked and bind:files”Checkboxes bind their boolean checked state with bind:checked; file inputs expose their selected FileList with bind:files:
<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>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.
Handling submit
Section titled “Handling submit”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:
<!-- @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 namehttps://svelte.dev/e/attribute_invalid_nameThe 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:
<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"]
Client-side is only half the story
Section titled “Client-side is only half the story”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.
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
bind:value coercion (number/range) | No manual Number(...) conversion; downstream code gets a real number | Only 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 describes | Recomputes on every relevant keystroke — fine for simple predicates, less so for an expensive async check (debounce that separately) |
e.preventDefault() inside the handler | Works identically for every event type, with no separate modifier syntax to learn | One 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 UX | Not trustworthy for persistence or security — a client can always be bypassed; needs a server-validated path (SvelteKit form actions) for anything that matters |
Common Misconceptions
Section titled “Common Misconceptions”- “
on:submit|preventDefaultstill works in Svelte 5, just not recommended.” It doesn’t compile at all on the modernonsubmitattribute — proven above:svelte-checkreports'onsubmit|preventDefault' is not a valid attribute name. The|modifiersyntax only ever existed for the legacyon: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
emailValidin its own$stateand updating it in anoninputhandler 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$derivedvalue 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$derivedis 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.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated form uses bind:/onsubmit, check for these before trusting it:
- Does
onsubmit={handler}(or any other modern event attribute) have a|modifiersuffix copied from Svelte 4 habits — a real, caught compile error, not a warning? - Is validation state stored in its own
$stateand manually kept in sync with the fields, instead of computed with$derived? - Does a number/range input’s bound value get run through
Number(...)/parseIntagain, 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 oldon:submit|preventDefaulthabit 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 namehttps://svelte.dev/e/attribute_invalid_nameThe 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}>