Make the Form Reactive
Right now, your booking form is like a piece of paper — users can write on it, but nobody’s paying attention to what they write. The form renders on screen, users can type in the fields, but Svelte has no idea what values they’re entering.
This is a problem because you need to:
- Show a live preview of the booking as users fill out the form
- Validate inputs (is the email valid? did they pick a date?)
- Submit the data to create an actual booking
- Save form progress so users don’t lose their work
All of these require knowing what users have typed. That’s where reactive state comes in.
From Static to Reactive
In the previous lesson, we built the HTML structure:
<input type="text" id="name" placeholder="Jane Smith" /> This input works — you can type in it. But the typed value lives only in the DOM (the browser’s internal representation). Your JavaScript code has no access to it. It’s isolated, disconnected from your component logic.
By adding $state and binding it to inputs, we create a bridge between what users type and what your JavaScript knows about. Every keystroke becomes observable, trackable, and usable.
What You’ll Learn
- Create
$statevariables for each form field - Understand how state tracks user input
- Set up the foundation for two-way binding
Add State Variables
The first step to making your form reactive is creating state variables that will hold the form data. Each input field needs a corresponding piece of state.
Why One Variable Per Field?
Our form has five fields:
- Customer name (text input)
- Email (email input)
- Date (date picker)
- Time (dropdown)
- Notes (textarea)
Each field’s value needs to be stored somewhere in JavaScript so we can access it. That’s what state variables are for — they’re containers that hold the current value of each field.
Adding the Script Block
Open BookingForm.svelte. Right now it only has HTML (the <form> and its fields). We need to add a <script> tag at the top to hold our JavaScript:
<!-- Add SCRIPT -->
<script>
let customerName = $state('')
let email = $state('')
let date = $state('')
let time = $state('')
let notes = $state('')
</script>
<form>
<h2>Book a Service</h2>
<!-- ... rest of form -->
</form> Understanding Each Declaration
Let’s break down what this line does:
<script>
let customerName = $state('');
</script> let— Declares a variable (standard JavaScript)customerName— The name of our variable (you could call it anything)$state('')— Creates reactive state initialized to an empty string- The empty string
''is the initial value — what the field contains before users type anything
Why Start With Empty Strings?
We initialize each field to '' (empty string) because:
- The form starts blank — users haven’t entered anything yet
- Empty strings are falsy, making it easy to check if a field is filled:
if (customerName) { ... } - It matches the HTML inputs, which also start empty
The State Exists, But It’s Not Connected Yet
Important: at this point, we’ve created five state variables, but they’re not connected to the form inputs. If users type in the “Your Name” field, customerName won’t update. And if you change customerName in code, the input won’t show it.
They exist in parallel universes — the state in JavaScript, the inputs in HTML — but they don’t communicate. That’s what we’ll fix in the next section with binding.
Connect State to Inputs
Now comes the magic. We have state variables, we have HTML inputs — we need to connect them so they stay in sync. Svelte provides a special directive called bind:value that creates this connection.
What is Two-Way Binding?
Two-way binding means changes flow in both directions:
- User types in input → State variable updates
- State variable changes → Input displays new value
This is powerful because:
- You don’t have to write event handlers to read input values
- You can programmatically change what’s shown in an input
- The input and state are always synchronized
Adding bind:value
Update your BookingForm.svelte to add bind:value to each input:
<!-- filename: src/lib/components/BookingForm.svelte -->
<script>
let customerName = $state('')
let email = $state('')
let date = $state('')
let time = $state('')
let notes = $state('')
</>
<form>
<h2>Book a Service</h2>
<div class="field">
<label for="name">Your Name</label>
<input type="text" id="name" name="name" placeholder="Jane Smith" bind:value={customerName} />
</div>
<div class="field">
<label for="email">Email Address</label>
<input type="email" id="email" name="email" placeholder="jane@example.com" bind:value={email} />
</div>
<div class="field">
<label for="date">Preferred Date</label>
<input type="date" id="date" name="date" bind:value={date} />
</div>
<div class="field">
<label for="time">Preferred Time</label>
<select id="time" name="time" bind:value={time}>
<option value="">Select a time...</option>
<option value="09:00">9:00 AM</option>
<option value="10:00">10:00 AM</option>
<option value="11:00">11:00 AM</option>
<option value="13:00">1:00 PM</option>
<option value="14:00">2:00 PM</option>
<option value="15:00">3:00 PM</option>
</select>
</div>
<div class="field">
<label for="notes">Additional Notes</label>
<textarea
id="notes"
name="notes"
rows="3"
placeholder="Any special requests?"
bind:value={notes}
></textarea>
</div>
<button type="submit">Request Booking</button>
</form> What Changed?
The only addition to each input is one attribute: bind:value={variableName}
For example, the name input went from:
<input type="text" id="name" name="name" placeholder="Jane Smith" /> To:
<input type="text" id="name" name="name" placeholder="Jane Smith" bind:value={customerName} /> That one line — bind:value={customerName} — creates the entire two-way connection.
How This Works Behind the Scenes
When you write bind:value={customerName}, Svelte generates code that:
- Sets the input’s initial value from
customerName(currently'') - Listens for input events (every time users type a character)
- Updates
customerNamewith the new value from the input - Triggers reactivity so any UI using
customerNamere-renders
All of this happens automatically. You don’t see the event listeners, you don’t write the update logic — Svelte handles it.
Why “bind” and Not Just “value”?
You might wonder why we write bind:value instead of just value. They’re different:
One-way (value):
<input value={customerName} /> This only sets the initial value. If users type, customerName doesn’t update.
Two-way (bind:value):
<input bind:value={customerName} /> This keeps them synchronized. User types → state updates. State changes → input updates.
Works with All Form Elements
Notice we used bind:value on:
- Text inputs (
<input type="text">) - Email inputs (
<input type="email">) - Date pickers (
<input type="date">) - Dropdowns (
<select>) - Textareas (
<textarea>)
The same bind:value syntax works for all of them. Svelte adapts to each element type automatically.
Verify It Works
At this point, everything should be working — but there’s no visual proof yet. Let’s add a temporary debug section that shows the state values in real-time. This will confirm that binding is working.
Add Debug Output
At the bottom of your BookingForm.svelte, after the closing </form> tag, add:
<!-- filename: src/lib/components/BookingForm.svelte -->
<script>
let customerName = $state('')
let email = $state('')
let date = $state('')
let time = $state('')
let notes = $state('')
</script>
<form>
<!-- ... form fields ... -->
</form>
<!-- Temporary debug output -->
<pre>
customerName: {customerName}
email: {email}
date: {date}
time: {time}
notes: {notes}
</pre> The <pre> tag preserves whitespace and shows the values exactly as they are in the state.
Test It
Now visit a service page (like http://localhost:5173/services/lawn-mowing) and:
- Look below the form — you should see the debug output showing all fields as empty
- Type in “Your Name” — watch the debug output update with each keystroke
- Type in the email field — the
emailline in debug output updates - Select a date — the
dateline shows the chosen date (format: YYYY-MM-DD) - Pick a time — the
timeline shows the selected value (like “09:00”) - Type in notes — the
notesline updates
What This Proves
If the debug output updates as you type, that means:
✅ $state is working — The values are being stored reactively
✅ bind:value is working — User input is updating the state variables
✅ Reactivity is working — The UI re-renders when state changes
✅ The full cycle works — Input → State → Display
If you don’t see updates, check that:
- You added
bind:value={variableName}to each input - Variable names match (e.g.,
customerNamein both script and binding) - You wrapped the initial values in
$state()
This is Temporary
We’ll remove this debug section soon. In the next lesson, we’ll replace it with a proper “booking preview” panel that shows this information in a polished way. But for now, it’s a valuable tool for confirming everything works.
How bind:value Works
To truly appreciate bind:value, let’s look at what you’d have to do without it. This will show you how much Svelte is doing for you behind the scenes.
The Manual Approach (Without Binding)
Without bind:value, you’d need to:
- Set the input’s value from state
- Listen for input events
- Read the new value from the event
- Update state manually
Here’s what that looks like:
<!-- Manual approach (don't do this) -->
<script>
let customerName = $state('')
function handleInput(event) {
customerName = event.target.value
}
</script>
<input type="text" value={customerName} oninput={handleInput} /> For every input, you need:
- A handler function
- An
oninputattribute - Code to read
event.target.value - Manual assignment to state
For five form fields, that’s five functions, lots of repetitive code.
The Svelte Way (With Binding)
bind:value does all of that automatically:
<!-- Svelte's binding (do this) -->
<script>
let customerName = $state('')
</script>
<input type="text" bind:value={customerName} /> One directive. Svelte generates all the event handling code at compile time. The result is:
- Less code to write
- Less code to maintain
- Fewer opportunities for bugs
- Cleaner, more readable components
What Svelte Generates
When Svelte compiles bind:value, it creates code similar to the manual approach, but optimized. You get the benefits without the verbosity.
This is a common pattern in Svelte — the framework handles boilerplate, you write declarative code that expresses what you want, not how to achieve it.
Performance Note
You might worry: “Does this create a new event listener for every keystroke?” No. Svelte sets up the listener once when the component mounts, and it efficiently updates state on each input event. The generated code is just as performant as hand-written event handlers.
Common Mistakes
Here are the most common mistakes beginners make when adding reactivity to forms, and how to avoid them.
Forgetting $state
<script>
// ❌ Not reactive — binding won't update the UI
let customerName = ''
</script>
<input bind:value={customerName} /><p>Booking for: {customerName}</p> What happens: The input updates customerName when you type (the binding works one-way), but the <p> tag doesn’t update because customerName isn’t reactive.
The fix:
let customerName = $state(''); Without $state, you’re using a regular JavaScript variable. It can be read and written, but changes don’t trigger UI updates.
Binding to the Wrong Property
<!-- ❌ Wrong — textContent isn't a form value -->
<input bind:textContent={customerName} />
<!-- ❌ Wrong — innerText isn't used for inputs -->
<input bind:innerText={customerName} />
<!-- ✅ Correct — value is the input's content -->
<input bind:value={customerName} /> The rule: For form elements, the property you want is almost always value:
<input>→bind:value<select>→bind:value<textarea>→bind:value
Other properties (like textContent or innerHTML) are for regular DOM elements, not form inputs.
Mismatched Variable Names
<script>
let customerName = $state('')
</script>
<!-- ❌ Variable name doesn't exist -->
<input bind:value={name} /> The variable name in bind:value={} must exactly match a variable in your script. Typos will cause errors.
Forgetting Curly Braces
<!-- ❌ Missing curly braces -->
<input bind:value=customerName />
<!-- ✅ Curly braces required for JavaScript expressions -->
<input bind:value={customerName} /> The {} tells Svelte “this is a JavaScript expression, not a string.”
Using bind:value on Non-Form Elements
<!-- ❌ Divs don't have a 'value' property -->
<div bind:value={customerName}></div>
<!-- ✅ For divs, you might want textContent -->
<div bind:textContent={customerName}></div>
<!-- ✅ Or just display the value -->
<div>{customerName}</div> bind:value is specifically for form inputs. For other elements, you either bind different properties or just display values with {}.
Summary
You’ve transformed your static form into a reactive form. Every field now has state that tracks what users type, and changes flow automatically between inputs and JavaScript.
What you built:
- Five
$statevariables tracking all form fields - Two-way bindings connecting inputs to state
- A debug view proving reactivity works
- The foundation for form validation, previews, and submission
Key takeaways:
- Create
$statevariables for values you want to track - Use
bind:value={variable}to connect inputs to state - Changes flow both ways: input → state and state → input
- The same
bind:valuesyntax works for text inputs, dropdowns, textareas, and date pickers - You can use separate variables or group related data in objects
What’s unlocked: Now that form data is tracked in state, you can:
- Show live previews of the booking
- Validate fields in real-time (“email must include @“)
- Enable/disable the submit button based on completeness
- Save drafts to localStorage
- Send data to a server
All of these build on the foundation you just created.
Next Steps
The form tracks data internally, but users can’t see it (except our debug output). Continue with Display Live Form Preview to show users their booking details as they type.