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