MaDr Logo Tags

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

Published on July 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.

Ever wonder just how little code you need to build something actually useful? Let me show you how to create a working todo app in Svelte 5 with fewer than 15 lines of code. This isn’t just some gimmick - you’ll walk away with a real understanding of Svelte 5’s reactive runes and how they make building UIs ridiculously simple.

Setting up our reactive state

First things first, we need a place to store our todos. In Svelte 5, we use the $state() rune to create reactive variables:

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

I’ve started with one todo item so the list isn’t empty, and it also shows us the shape of our data: each todo has text (what you see) and done (whether it’s completed).

Counting completed todos

Now let’s keep track of how many todos are actually done. For this, we’ll use $derived() - it automatically recalculates whenever our todos change:

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

This line filters through all our todos and counts only the ones marked as done. Pretty neat, right?

Adding new todos

Let’s add an input field so users can add new items to their list:

<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} />

The onchange function does two things: adds a new todo with whatever text is in the input, then clears the input field. The && trick is just a compact way to chain these operations.

Displaying our todos

Now we need to actually show our todos. We’ll use Svelte’s {#each} loop:

<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}

The bind:value and bind:checked attributes are Svelte magic - they create a two-way connection between the UI and our data. Type in the text field and the todo updates instantly. Click the checkbox and the done state flips.

Adding a completion counter

Let’s show how many todos are completed:

<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>

And just like that, we have a fully functional todo app in about 15 lines! But wait - we can make it even shorter.

Shrinking it down (just for fun)

Fair warning: the code below is great for impressing your friends, but maybe skip it for production code. Let’s compress this thing:

First, Svelte lets us use shorthand when the variable name matches the attribute name:

<input {onchange} />

Then we can combine our variable declarations with commas:

<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>

Finally, strip out unnecessary whitespace:

<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}

Fourteen lines. That’s it. Fourteen lines of Svelte 5 code give you a todo app that actually works.

Try it yourself

Want to play with this code? I’ve got you covered:

Go ahead - break it, extend it, make it your own. That’s how we learn.

What’s next?

From here you could add delete buttons, local storage persistence, or styling. But even in this minimal form, you can see why Svelte 5’s reactive system is so powerful - you get a working UI with barely any boilerplate.