A functional todo applet in Svelte 5, using less than 15 lines of code?

Published on 7/20/2025

Written by MaDrCloudDev

Can we build a functional todo applet in Svelte 5, using less than 15 lines of code?

The Astro logo on a dark background with a pink glow.

First, we declare an array, todos, using $state() . We wrap todos with the $state() rune, because it is a reactive variable; it changes when we add or remove todos from our list (array):

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
</script>

We’ve included a default todo { text: 'Fly to the moon', done: false } so our todos list isn’t empty, and to define our schema: text, a string, for the label and done, a boolean (true or false) for completion state.

Next, we declare a variable called done wrapped with the $derived() rune. We wrap done with $derived() because it’s a reactive variable that depends on existing state (it’s part of every todo):

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
</script>

Our function inside this $derived() filters our todo array, finding the todos that are marked done: true, using the .length of the filtered array to track our count of completed todos.

We’re going to add a text-input field for adding todos, because it makes it easy to both edit and update todos in real-time (as we type/edit them). We want to bind a button to this input that we can hit to add a new todo.

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
</script>

<input onchange={} />

I’ve added Svelte’s onchange event , because we want our todos to be added when we hit a certain key (or click away). We need to define how onchange behaves by defining a function (also) called onchange that we’ll pass to the event to (I’ve purposely matched our function’s name to the Svelte’s event-handler’s name. More on that later).

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
	let onchange = (e) => todos.push({ text: e.target.value,
        done: false }) && (e.target.value = '');
</script>

<input onchange={onchange} />

Our onchange function says: if anything changes, push a un-completed todo onto our list, where the text of that todo is the text we typed into our text-box, and then clear the input by resetting it to '' after the todo is added.

But we’ve gotten a bit ahead of ourselves. We need to display our todos in a list. We’ll use Svelte’s {#each} loop to render each todo in a <div> below the text-input:

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
	let onchange = (e) => todos.push({ text: e.target.value,
        done: false }) && (e.target.value = '');
</script>

<input onchange={onchange} />

{#each todos as todo}
	<div>
		<input />
        <input type="checkbox" />
	</div>
{/each}

We’re going to render an additional <input> field, that we’ll use to flip the state of done to true or false using a checkbox. And we’re going to add Svelte’s bind:value attribute to sync the text-input with todo.text, and ‘bind:checked’ to sync our checkbox with todo.done. This is what’s known as a two-way binding, and it makes it easy to edit and update our todos in real-time, using minimal inputs.

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
	let onchange = (e) => todos.push({ text: e.target.value,
        done: false }) && (e.target.value = '');
</script>

<input onchange={onchange} />

{#each todos as todo}
	<div>
		<input bind:value={todo.text} />
        <input type="checkbox" bind:checked={todo.done} />
	</div>
{/each}

Let’s add a counter for completed todos to the bottom of our list. using a <div> that dynamically tracks the value of `done’.

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
	let onchange = (e) => todos.push({ text: e.target.value,
        done: false }) && (e.target.value = '');
</script>

<input onchange={onchange} />

{#each todos as todo}
	<div>
		<input bind:value={todo.text} />
        <input type="checkbox" bind:checked={todo.done} />
	</div>
{/each}

<div>
	Done: {done}
</div>

This gives us a functional todo component in ~15 lines, but we can condense it even further.

Condensing the code

To showcase Svelte’s conciseness, we’ll minimize the code using a few tricks. I wouldn’t recommend this in production, but it’s a fun way to learn. First, we’ll change <input onchange={onchange} /> to <input {onchange} /> to use Svelte’s shorthand syntax:

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
	let onchange = (e) => todos.push({ text: e.target.value,
        done: false }) && (e.target.value = '');
</script>

<input {onchange} />

{#each todos as todo}
	<div>
		<input bind:value={todo.text} />
        <input type="checkbox" bind:checked={todo.done} />
	</div>
{/each}

<div>
	Done: {done}
</div>

The {onchange} shorthand works because the event handler’s name matches the function name of our event. Next, we remove the <div> around Done: {done}, as Svelte components are already wrapped in <html> and <body> tags:

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]);
	let done = $derived(todos.filter((t) => t.done).length);
	let onchange = (e) => todos.push({ text: e.target.value,
        	done: false }) && (e.target.value = '');
</script>

<input {onchange} />
{#each todos as todo}
	<div>
		<input bind:value={todo.text} />
        <input type="checkbox" bind:checked={todo.done} />
	</div>
{/each}
Done: {done}

Then, we combine variable declarations with a single let, separating them with commas (a feature of JavaScript):

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]),
		done = $derived(todos.filter((t) => t.done).length),
		onchange = (e) => todos.push({ text: e.target.value,
            done: false }) && (e.target.value = '');
</script>

<input {onchange} />
{#each todos as todo}
	<div>
		<input bind:value={todo.text} />
        <input type="checkbox" bind:checked={todo.done} />
	</div>
{/each}
Done: {done}

Finally, we reduce whitespace for maximum compactness:

<script>
	let todos = $state([{ text: 'Fly to the moon', done: false }]),
		done = $derived(todos.filter((t) => t.done).length),
		onchange = (e) => todos.push({ text: e.target.value,
			done: false }) && (e.target.value = '');
</script>
<input {onchange} />
{#each todos as todo}
	<div>
		<input bind:value={todo.text} />
        <input type="checkbox" bind:checked={todo.done} />
	</div>
{/each}
Done: {done}

This 14-line app showcases Svelte 5’s reactive runes and concise syntax.

Further development might include adding a delete button to delete todos or adding data persistence, but this minimal todo app demonstrates Svelte 5’s power in 15 lines.

Check out: