Forms and validation: the form that says why¶
Exercise forms-validation-annex.pdf · PDF
Have you ever filled in a long form, pressed Send, and got it back empty? Or seen "Invalid input", with no clue which input? Which field? What rule? Why now?
A form is the place where the user gives you something. It is the most expensive screen to get wrong, because every failure costs the user's time, twice. And nobody types wrong data for fun. If the form refuses, it owes an explanation.
What you will learn
- Build a form whose fields are tied to state with
bind:valueandbind:checked - Handle
onsubmit, and explain whatevent.preventDefault()prevents - Use the browser's built-in validation, and name its limits
- Write validation rules as a
$derivedobject of error messages - Decide when and where an error appears, and write it in plain words
- Make errors reachable by assistive technology:
label,aria-invalid,aria-describedby - The trap: a disabled Save button explains nothing
Passport control¶
Think of passport control. The officer checks your documents before you board, not when you land. A good officer says "your passport expired in March", and you stay at the desk to fix it. A bad one says "no" and sends you to the back of the queue. We keep this officer all page.
Validation is checking that the data meets the rules before using it. An error message is what the officer says when it does not.
The form is the desk. Every rule must answer three questions for the user:
- Which field?
- What is wrong?
- How do I fix it?
"The title needs at least 3 characters." answers all three, in one short sentence. "Invalid input" answers none. This is the clear, brief messaging you saw in Controls, menus and messages, now in code.
These are NoteBox's rules. They are the spec, so the code copies the texts exactly:
| Field | Rule | Message | Shown |
|---|---|---|---|
| Title | At least 3 characters, ignoring spaces at the ends | The title needs at least 3 characters. | After the first Save |
| Title | At most 40 characters | The title can have at most 40 characters. | After the first Save |
| Note | At most 280 characters | The note can have at most 280 characters. | At once |
Fields tied to state¶
Create a new component for the form, in src/lib/NoteForm.svelte. Its styles come with the full version further down:
<script>
let { onsave } = $props();
let title = $state('');
let body = $state('');
let tag = $state('none');
let pinned = $state(false);
</script>
<form>
<div class="field">
<label for="title">Title</label>
<input id="title" type="text" bind:value={title} />
</div>
<div class="field">
<label for="body">Note</label>
<textarea id="body" rows="4" bind:value={body}></textarea>
<p class="counter">{body.length} / 280</p>
</div>
<div class="field">
<label for="tag">Tag</label>
<select id="tag" bind:value={tag}>
<option value="none">No tag</option>
<option value="home">Home</option>
<option value="school">School</option>
<option value="work">Work</option>
</select>
</div>
<label class="check">
<input type="checkbox" bind:checked={pinned} />
Pin this note
</label>
<button type="submit">Save note</button>
</form>
Three things to notice:
bind:valueis two-way: typing changes the variable, and changing the variable changes the field. Plainvalue={title}only writes into the field and never reads back. A checkbox has no usefulvalue, so it usesbind:checked.- Each
<label for="x">points at the control withid="x". Click the word "Title" and the cursor jumps into the field. A screen reader reads the label when the field gets focus. A placeholder is no substitute: it disappears when you type and is often too pale to read. - The counter is not state. It is an expression on
body.
Now put it on the page. Import it, place it above the search, and delete the old New note button and its .new style:
<script>
import NoteForm from '#lib/NoteForm.svelte';
// ...
</script>
<NoteForm onsave={addNote} />
Submit¶
Fill in the form and press Save. What should a form do? By default it sends its data and loads a new page. That is how the web worked in 1995, and in a plain HTML page it still does: a full reload, and everything in memory is gone.
SvelteKit changes what you see, so try it. Its router catches this kind of form, as it catches links, and makes the navigation without a real reload:
| You try | What happens |
|---|---|
| The form as it is | Seemingly nothing. No note is saved, and no error appears |
Add name="title" to the input |
The address gains ?title=.... The data went to the URL, not to your list |
Add data-sveltekit-reload to the <form> |
The real thing: a full reload, and the fields come back empty |
Remove those experiments. None of them is what you want, so you take over:
<script>
// ...
function submit(event) {
event.preventDefault();
onsave({ title: title.trim(), body, tag, pinned });
title = '';
body = '';
tag = 'none';
pinned = false;
}
</script>
<form onsubmit={submit}>
event.preventDefault() says: "browser, do not do your usual thing, I will handle it". Then you ring the parent's bell with the data, and empty the fields. .trim() removes spaces at both ends.
On the page's side, addNote now receives the data:
...data spreads the four fields into the new object. Save a note: it appears in the list, and it survives a reload thanks to the effect from Reactivity.
Press Enter inside the title field. It submits too. That is why you listen to the form's submit, not to the button's click.
The browser's own checks¶
Press Save with everything empty: an empty note is created. Now add three attributes to the title input: required minlength="3" maxlength="40". Save again. The browser shows a bubble and refuses. Free validation, zero JavaScript.
Now look for its limits:
| The browser's validation... | So... |
|---|---|
| Shows one error at a time, in a bubble that disappears | The user fixes one, submits, and meets the next |
| Writes the message itself, in the browser's language | You cannot make it say what your rules say |
| Counts three spaces as three characters | " " is a valid title |
| Cannot compare two fields with these attributes | "A pinned note needs a tag" is impossible |
| Looks different in every browser | It will never match your design |
These attributes are a fine first line of defence in small forms. For NoteBox you take control: remove them, and add novalidate to the <form> tag, which tells the browser to stay out.
Careful: no check in the browser is final
Anyone can send a request without your form. Any check in the browser can be skipped by someone who wants to, so a server must always check again. You will see that in Consuming data.
Rules as derived values¶
Is "the title is too short" state? Do you ever set it? No: it is a formula on title. What you learned in Reactivity pays off here: errors are derived.
One object, one message per field. An empty string means "no error".
let errors = $derived({
title:
title.trim().length < 3
? 'The title needs at least 3 characters.'
: title.trim().length > 40
? 'The title can have at most 40 characters.'
: '',
body: body.length > 280 ? 'The note can have at most 280 characters.' : ''
});
let valid = $derived(errors.title === '' && errors.body === '');
If you set errors by hand in a handler, they go stale the moment the user types. Derived, they cannot.
And the gate, as the second line of submit:
To watch it work, temporarily put <pre>{JSON.stringify(errors, null, 2)}</pre> under the form and type: the object changes at every key. Save with a two-letter title: nothing happens. The rule works. And what does the user know? Nothing. You built a silent officer.
Validation is really two jobs. The rule decides valid or not. The display decides when and where to say it. A rule nobody can see is the same as no rule.
Showing errors well¶
When? If you show errors.title as it is, an empty form opens already shouting in red. Has the user done anything wrong yet? An error before any action is an accusation; rules belong in labels and hints.
So the title error waits for the first Save. The note error appears at once, because the user is already typing past the limit. That takes one new piece of state, submitted.
Where? Under the field, in the danger colour, with the field outlined:
<input
id="title"
type="text"
bind:value={title}
aria-invalid={submitted && errors.title !== ''}
aria-describedby="title-error"
/>
<p id="title-error" class="error">{submitted ? errors.title : ''}</p>
Why those attributes? Colour alone is not enough: about 1 in 12 men cannot rely on red, as you saw in Design principles.
aria-invalidtells a screen reader "this field has a problem".aria-describedbytells it which text explains the problem.
You style the red border from aria-invalid, so the look and the meaning can never disagree. And the error line always exists (min-height: 18px): when a message appears, nothing below it jumps.
Here is the whole component, with the <pre> removed:
<script>
let { onsave } = $props();
let title = $state('');
let body = $state('');
let tag = $state('none');
let pinned = $state(false);
let submitted = $state(false);
let errors = $derived({
title:
title.trim().length < 3
? 'The title needs at least 3 characters.'
: title.trim().length > 40
? 'The title can have at most 40 characters.'
: '',
body: body.length > 280 ? 'The note can have at most 280 characters.' : ''
});
let valid = $derived(errors.title === '' && errors.body === '');
function submit(event) {
event.preventDefault();
submitted = true;
if (!valid) return;
onsave({ title: title.trim(), body, tag, pinned });
title = '';
body = '';
tag = 'none';
pinned = false;
submitted = false;
}
</script>
<form onsubmit={submit} novalidate>
<div class="field">
<label for="title">Title</label>
<input
id="title"
type="text"
bind:value={title}
aria-invalid={submitted && errors.title !== ''}
aria-describedby="title-error"
/>
<p id="title-error" class="error">{submitted ? errors.title : ''}</p>
</div>
<div class="field">
<label for="body">Note</label>
<textarea
id="body"
rows="4"
bind:value={body}
aria-invalid={errors.body !== ''}
aria-describedby="body-error"
></textarea>
<p class={['counter', body.length > 280 && 'over']}>{body.length} / 280</p>
<p id="body-error" class="error">{errors.body}</p>
</div>
<div class="field">
<label for="tag">Tag</label>
<select id="tag" bind:value={tag}>
<option value="none">No tag</option>
<option value="home">Home</option>
<option value="school">School</option>
<option value="work">Work</option>
</select>
</div>
<label class="check">
<input type="checkbox" bind:checked={pinned} />
Pin this note
</label>
<button type="submit">Save note</button>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: 16px;
margin-bottom: 24px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
label {
font-size: 14px;
font-weight: 500;
}
input[type='text'],
textarea,
select {
padding: 10px 12px;
font: inherit;
border: 1px solid var(--nb-ink);
border-radius: var(--nb-radius);
}
.counter {
margin: 0;
font-size: 13px;
text-align: right;
color: var(--nb-muted);
}
.check {
display: flex;
align-items: center;
gap: 8px;
font-weight: 400;
}
button {
height: 44px;
font: inherit;
font-weight: 500;
color: var(--nb-paper);
background: var(--nb-ink);
border: 0;
border-radius: var(--nb-radius);
cursor: pointer;
}
:is(input, textarea, select)[aria-invalid='true'] {
border-color: var(--nb-danger);
outline: 1px solid var(--nb-danger);
}
.error {
min-height: 18px;
margin: 0;
font-size: 13px;
color: var(--nb-danger);
}
.over {
color: var(--nb-danger);
}
</style>
flowchart LR
F["<b>$state</b><br>title, body, tag, pinned"] -->|"formula"| E["<b>$derived</b><br>errors, valid"]
E -->|"the gate"| S{"<b>submit</b><br>valid?"}
S -->|"yes"| P["onsave(...)<br>the page keeps the note"]
S -->|"no"| M["<b>Message</b><br>under the field,<br>with aria-invalid"]
E -->|"the text"| M
Careful: do not disable Save until the form is valid
It sounds tidy. But the user stares at a grey button and has no idea why. A disabled button cannot explain; an error message can. Let them press, then say what is missing.
Your turn: blur, a cross-field rule and Clear¶
The handout at the top of this page asks for three things: show the title error when the user leaves the field, add a rule that compares two fields ("A pinned note needs a tag."), and add a Clear button. If you get stuck:
| You see | Why |
|---|---|
| Clear submits the form | A <button> inside a form is type="submit" unless told otherwise. Use type="button" |
| Clear empties the fields but the errors stay | You did not reset the state that decides whether errors show |
The tag rule is an if inside submit |
Look at where the other rules live: one more key in errors, one more condition in valid |
onblur={touched = true} throws an error in the console |
The handler must be a function, so it needs an arrow: () => (touched = true) |
The tag error has no id or aria-describedby |
Think about what someone using a screen reader would hear |
| You rewrote the message ("Tag required!") | The rules are the spec: use the exact text |
Then build the main form of your own app: at least four fields of three different kinds, at least three rules whose messages answer the three questions, and a cross-field rule if your design has one.
Key takeaways¶
- Validation checks data before you use it. Every error message answers three questions: which field, what is wrong, how to fix it.
bind:valueandbind:checkedtie fields to state in both directions. Every field needs a real<label>.- Listen to the form's
onsubmitand callevent.preventDefault(): a form's default is to send its data and load a page. - Browser attributes (
required,minlength) are free, but they show one error at a time, in their own words.novalidateturns them off. - Errors are derived from the fields, never set by hand. The rule decides; the display decides when and where to say it.
aria-invalidandaria-describedbymake errors reachable by assistive technology. Text next to the field says it for everyone. A server must always check again.
Quick check¶
1. What is the difference between value={title} and bind:value={title} on an input?
-
valueonly writes into the field, andbind:valuealso reads what the user types -
valueworks only for text fields, andbind:valueworks only for checkboxes -
valueupdates on every key, andbind:valueupdates only when the form is sent -
valueneeds a$statevariable, andbind:valueneeds a plain variable
bind:value is two-way: typing changes the variable, and changing the variable changes the field. A checkbox uses bind:checked instead.
2. Which error message answers all three questions a rule must answer?
- "The title needs at least 3 characters."
- "Invalid input. Please try again."
- "Something is wrong with this form."
- "Error 422: validation has failed."
It names the field, says what is wrong and shows how to fix it. The others leave the user guessing.
3. Why do you listen to the form's onsubmit rather than to the Save button's onclick?
- Because pressing Enter in a field also submits the form
- Because a button inside a form cannot have an
onclick - Because
onclickfires only after the page has reloaded - Because
onsubmitvalidates all the fields automatically
A form can be sent in more than one way. onsubmit catches the button and the Enter key. Validating is still your job.
4. What does event.preventDefault() do in a form's submit handler?
- It stops the form's default behaviour of sending its data and loading a page
- It stops the user from typing anything else into the fields of the form
- It checks the
requiredattributes of all the fields before sending them - It sets every field of the form back to the value it had at the start
By default a form sends its data and navigates. preventDefault() says "I will handle it", so your code decides what happens to the data.
5. Which is a limit of the browser's built-in validation?
- It accepts a title of three spaces as three characters
- It needs a JavaScript library installed before it works
- It only works in forms that have fewer than three fields
- It sends every value to a server in order to check it
minlength counts spaces. The browser also shows one error at a time, in its own words. That is why NoteBox uses novalidate and its own rules.
6. Why are errors written as $derived rather than set in the submit handler?
- Because they are a formula on the fields, so they can never go stale
- Because a
$statevariable cannot hold an object with text inside - Because the submit handler is not allowed to change any variable
- Because
$derivedsends the errors to the screen reader by itself
Nobody sets "the title is too short": it follows from title. Set by hand, an error stays on screen after the user has already fixed the field.
7. What does aria-describedby='title-error' do on an input?
- It tells assistive technology which text explains the field's problem
- It turns the border of the field red whenever there is an error
- It stops the form from being sent while the field has an error
- It creates a new paragraph with that id below the field
It links the field to the text with that id, so a screen reader can read the explanation. aria-invalid is what says the field has a problem at all.
8. A booking form has a check-in date and a check-out date. Check-out must be after check-in. How should you build this rule?
- One more key in the
$derivederrors object, comparing the two dates - A
minattribute on each date field, so the browser handles all of it - An
ifin the submit handler that sets a new$statewith the message - A disabled submit button that becomes active when the dates are right
A cross-field rule is still a formula on the fields, so it lives with the other rules and updates as the user types. A disabled button could not explain what is wrong.
NoteBox now listens and explains, but it still lives in a phone-sized column in the middle of a huge screen. Next you teach it to use the space: Layout with CSS.