Client-Side Web Applications

Events & Dynamic Rendering


Learning Objectives

  • You can connect event handlers and bindings to local state.
  • You can use conditional and keyed list rendering.
  • You can test identity and filtering behavior across a sequence of interactions.

State describes the current situation. Events provide one way to change it. Conditional and list rendering then describe which document should result. We will connect those ideas without bringing in a network request yet.

Pass a handler; do not call it while rendering

These two expressions do different things:

<button type="button" onclick={markPresent}>Mark present</button>
<button type="button" onclick={() => markPresent()}>Mark present</button>

Both supply a function for a later event. In contrast, onclick={markPresent()} calls the function while evaluating the expression and uses its return value as the handler. Unless the function intentionally returns another function, that is not the intended behavior.

For the pinned Svelte style, use event attributes such as onclick and oninput. The attribute contains a JavaScript expression, not a quoted string of JavaScript source. See Svelte event attributes.

A seat-selection button

0 / 5 points

selectSeat(id) changes state for one row and returns nothing. Inside an each block, a button must select the current seat.id only when activated. Which form supplies that behavior?

Conditional output and keyed lists

Create client/src/lib/examples/Attendance.svelte:

<script lang="ts">
let people = $state([
{ id: "mira", name: "Mira", present: false },
{ id: "toni", name: "Toni", present: true },
]);
let onlyAbsent = $state(false);
let visible = $derived(onlyAbsent ? people.filter((p) => !p.present) : people);
let presentCount = $derived(people.filter((p) => p.present).length);
</script>
<label>
<input type="checkbox" bind:checked={onlyAbsent} />
Show only absent people
</label>
<p aria-live="polite">{presentCount} present</p>
{#if visible.length === 0}
<p>No people match this view.</p>
{:else}
<ul>
{#each visible as person (person.id)}
<li>
<span>{person.name}: {person.present ? "present" : "absent"}</span>
<button type="button" onclick={() => (person.present = !person.present)}>
{person.present ? `Mark ${person.name} absent` : `Mark ${person.name} present`}
</button>
</li>
{/each}
</ul>
{/if}

The checkbox changes onlyAbsent; the button changes a person’s state. visible and presentCount are derived. Filtering does not delete the underlying people.

The key (person.id) identifies an item across changes. An array index describes a position, not a durable identity. When items are reordered, inserted, or filtered, position-based identity can associate local component state or focus with the wrong item.

Svelte’s each blocks describe keyed rendering. A key must be unique among that list’s items and stable for the represented record. Do not generate a new random key on every render.

Favorites after filtering

0 / 5 points

A recipe is marked as a favorite, hidden by a filter, then shown again. Its favorite state must remain with that recipe. Which design best expresses that requirement?

Bindings connect an input and state

bind:checked synchronizes a checkbox with a Boolean. For text, a common form is:

<script lang="ts">
let query = $state("");
</script>
<label for="query">Search by name</label>
<input id="query" bind:value={query} />
<p>Current query: {query}</p>

Binding a value does not submit a form, save to PostgreSQL, or validate the value on a server. It is a local interaction mechanism. Part 4 adds submission and mutation behavior.

An explicit oninput handler is also useful when the interaction needs a transformation. Be precise about the event target and its type in TypeScript rather than assuming every event originates from the same kind of element.

A child communicates intent

Move a person’s display into a component only after understanding who owns the state. A callback prop can report intent without having the child mutate the parent’s object.

For example, create AttendanceButton.svelte:

<script lang="ts">
let {
name,
present,
onToggle,
}: {
name: string;
present: boolean;
onToggle: () => void;
} = $props();
</script>
<button type="button" onclick={onToggle}>
{present ? `Mark ${name} absent` : `Mark ${name} present`}
</button>

The parent supplies the values and a callback. It can change state, ask for confirmation, or later call an API. The child does not need to know those policy decisions. This is a small example of separating user intent from its implementation.

Test behavior across more than one state

Create Attendance.test.ts beside the component:

import { cleanup, render, screen } from "@testing-library/svelte";
import { userEvent } from "@testing-library/user-event";
import { afterEach, expect, it } from "vitest";
import Attendance from "./Attendance.svelte";
afterEach(cleanup);
it("filters the view without removing the underlying person", async () => {
const user = userEvent.setup();
render(Attendance);
await user.click(screen.getByRole("checkbox", { name: "Show only absent people" }));
expect(screen.queryByText("Toni: present")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Mark Mira present" }));
expect(screen.getByText("No people match this view.")).toBeInTheDocument();
await user.click(screen.getByRole("checkbox", { name: "Show only absent people" }));
expect(screen.getByText("Mira: present")).toBeInTheDocument();
expect(screen.getByText("Toni: present")).toBeInTheDocument();
});

queryBy... is useful for an element that should be absent. getBy... reports a failure immediately when an expected element is missing. The testing library’s query guidance explains the distinction.

This test catches a bug that a simple “button clicks” assertion would miss: incorrectly removing people from stored state while filtering the view.

A component can render a nested structure

We have rendered a flat list. Some interfaces contain the same structure inside itself: an outline topic can contain more topics. A recursive component renders one topic and then delegates each child to another instance of itself.

For a small neutral example, create client/src/lib/examples/TopicBranch.svelte:

<script lang="ts">
import TopicBranch from "./TopicBranch.svelte";
type Topic = { id: string; title: string; children: Topic[] };
let { topic }: { topic: Topic } = $props();
</script>
<li>
<span>{topic.title}</span>
{#if topic.children.length > 0}
<ul>
{#each topic.children as child (child.id)}
<TopicBranch topic={child} />
{/each}
</ul>
{/if}
</li>

Render it inside a ul on /lab, passing a topic with an empty children array, then one with a child and grandchild. The empty array is the stopping case: it creates no further component instances. Each child receives its own input.

The example begins with already nested data. In the marketplace you will later receive flat parent references and construct that nested shape yourself. Keep those responsibilities separate. The later exercise assumes valid parent references without cycles; detecting invalid graphs is outside its scope.

A leaf topic

0 / 5 points

A recursive branch loops over topic.children, but mistakenly passes topic rather than child to every nested Branch. What is the central problem when the original topic has children?

Apply derived totals in an interactive list

Now we have the list, event and binding mechanisms needed for the playlist. Store which tracks are selected and derive the duration. Removing a track should restore allowance; adding one should never exceed it. This is the interactive counterpart to the preceding chapter’s pure calculation, but starts from its own supplied files.

Select tracks within an allowance

0 / 20 points

Task

Complete src/lib/Playlist.svelte. It receives tracks (unique IDs, titles, positive integer minutes) and limit (a nonnegative integer). The grader keeps these inputs fixed during an interaction. Use local selection state and derive the totals, as in the reading-progress example.

Requirements

Initially nothing is selected. Show a paragraph with role="status" and <used> minutes selected; <remaining> minutes remaining. Each track has a native button named Add <title> or Remove <title> according to selection. Adding a track at exactly the remaining allowance succeeds; a larger addition must be disabled. Selected tracks can always be removed. Removal restores the allowance and re-enables additions that now fit. Match tracks by their stable ID and leave the supplied collection unchanged. Separate component instances must not share selection.

Submit

Submit only src/lib/Playlist.svelte. The supplied editor has no live Run preview. Four checks award 5 points each: initial output, exact-limit selection, removal/recovery, and independent instances. Do not fetch or persist anything.

Combine filtering with stable identity

A second interface combines a text filter, a Boolean filter, and choices that must survive filtering. Use stable recipe identity rather than a row’s current position. The exercise describes the controls and submission files; keep the worked attendance example in the practice application.

A favorite control is a toggle. Keep it as a native button and set aria-pressed from the current favorite state. The attribute communicates whether the choice is active and must change with that state; it does not store the state itself.

Keep favorites while filtering

0 / 20 points

Task

Complete src/lib/Recipes.svelte. The recipes prop contains distinct string IDs, unique names, and a Boolean vegetarian flag. It stays fixed during this exercise.

Requirements

Provide a text input labelled Search recipes and a checkbox labelled Vegetarian only. Match the trimmed text case-insensitively within the name. Both filters apply together. Empty search text imposes no text restriction. Preserve input order. With no matches, display No matching recipes..

Each visible recipe has a button named Favorite <name> with aria-pressed reflecting its local favorite state. Favorites start false and toggle. Filtering a recipe out and back must preserve its favorite state by ID; it must not move to another recipe. Do not remove records from or mutate the supplied collection. Use native controls; no network persistence is required.

Submit

Submit only src/lib/Recipes.svelte. Four checks award 5 points each: combined filters, explicit empty/recovery states, favorite identity across filters, and unchanged inputs. The grader supplies the exercise environment and tests.

Check Your Understanding

  1. Why is onclick={handler()} usually different from onclick={handler}?
  2. Why should filtering change the view rather than delete records?
  3. What does a stable key protect when a list changes order?
  4. Why might a callback prop be preferable to mutating a parent’s object?
  5. Which parts of the recipe interaction disappear on a full reload, and why?