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 $state variables 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:

  1. Customer name (text input)
  2. Email (email input)
  3. Date (date picker)
  4. Time (dropdown)
  5. 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:

  1. User types in input → State variable updates
  2. 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:

  1. Sets the input’s initial value from customerName (currently '')
  2. Listens for input events (every time users type a character)
  3. Updates customerName with the new value from the input
  4. Triggers reactivity so any UI using customerName re-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:

  1. Look below the form — you should see the debug output showing all fields as empty
  2. Type in “Your Name” — watch the debug output update with each keystroke
  3. Type in the email field — the email line in debug output updates
  4. Select a date — the date line shows the chosen date (format: YYYY-MM-DD)
  5. Pick a time — the time line shows the selected value (like “09:00”)
  6. Type in notes — the notes line 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., customerName in 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:

  1. Set the input’s value from state
  2. Listen for input events
  3. Read the new value from the event
  4. 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 oninput attribute
  • 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 $state variables 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 $state variables 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:value syntax 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.