Skip to main content

I built a todo app in under 15 lines of Svelte 5. How do we make it production-ready?

Updated on March 14, 2026

Written by MaDrCloudDev

Let's take the tiny Svelte 5 todo app and make it behave more like a real app, without turning it into a giant codebase.

A stylized illustration showing a tiny code snippet evolving into a polished Svelte todo app with checklist items, save status, and a shield badge.

In the previous post, we built a tiny Svelte todo app in under 15 lines of code. That version was fun, and it already had some nice qualities: the list updated reactively, the counter stayed in sync, and we could edit todo text inline.

In this tutorial, we’re going to keep that same little app shape and make it more practical without turning it into a different app. No huge rewrite. No extra abstraction. Just a few upgrades that make the original version feel more usable day to day: safer input handling, stable ids, cleaner rows, delete buttons, and persistence.

Step 0: exact starting point from the previous tutorial

We’ll start from the exact final code in the previous post.

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

Everything below grows directly from that file.

Step 1: move new-todo input into a small form

The first thing worth improving is the add flow. In the tiny version, we were reading straight from e.target.value inside onchange. That works, but it makes validation awkward, and it only fires when the value is committed.

So instead, we’ll give the input its own state with newTodo, and move the add logic into a small addTodo() function. While we’re here, we’ll also add ids so each todo has a stable identity once we start deleting items later.

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

	function addTodo(e) {
		e.preventDefault();

		const text = newTodo.trim();
		if (!text) return;

		todos.push({ id: nextId, text, done: false });
		nextId += 1;
		newTodo = '';
	}
</script>

<form onsubmit={addTodo}>
	<input bind:value={newTodo} placeholder="Build something fun" />
	<button>Add</button>
</form>

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

Done: {done}

This is still very close to the original app:

  • We still have one todos array
  • We still edit todo text inline
  • We still toggle completion with bind:checked
  • We still derive the completed count from the same data

The only real changes are around how new todos are added.

newTodo.trim() also gives us a simple guard against blank items, which is one of those tiny changes that immediately makes the app feel more real.

Step 2: clean up each row with strike-through, edit mode, and delete

Now let’s make each todo row feel a bit more intentional. Instead of always showing a text input, we’ll show plain text by default, strike it through when the todo is checked, and only swap in an input when the edit button is clicked.

We’ll also replace the plain Delete text with a red button.

<script>
	import { tick } from 'svelte';

	let todos = $state([
		{ id: 1, text: 'Fly to the moon', done: false }
	]);
	let newTodo = $state('');
	let editingId = $state(null);
	let editText = $state('');
	let editInput = $state(null);
	let nextId = 2;
	let done = $derived(todos.filter((t) => t.done).length);
	let remaining = $derived(todos.length - done);

	function addTodo(e) {
		e.preventDefault();

		const text = newTodo.trim();
		if (!text) return;

		todos.push({ id: nextId, text, done: false });
		nextId += 1;
		newTodo = '';
	}

	function removeTodo(id) {
		todos = todos.filter((todo) => todo.id !== id);
		if (editingId === id) {
			editingId = null;
			editText = '';
		}
	}

	async function focusEditInput() {
		await tick();
		if (!editInput) return;
		editInput.focus();
	}

	function handleEditButton(todo) {
		if (editingId === todo.id) {
			saveEdit();
			return;
		}

		if (editingId !== null) {
			saveEdit();
		}

		editingId = todo.id;
		editText = todo.text;
		void focusEditInput();
	}

	function saveEdit() {
		if (editingId === null) return;

		const text = editText.trim();
		const todo = todos.find((t) => t.id === editingId);

		if (todo && text) {
			todo.text = text;
		}

		editingId = null;
		editText = '';
	}
</script>

<form onsubmit={addTodo}>
	<input bind:value={newTodo} placeholder="Build something fun" />
	<button>Add</button>
</form>

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

		{#if editingId === todo.id}
			<input bind:this={editInput} bind:value={editText} onblur={saveEdit} />
		{:else}
			<span style={todo.done ? 'text-decoration: line-through;' : ''}>
				{todo.text}
			</span>
		{/if}

		<button
			type="button"
			onpointerdown={(e) => e.preventDefault()}
			onclick={() => handleEditButton(todo)}
		>
			✏️
		</button>

		<button type="button" onclick={() => removeTodo(todo.id)}>

		</button>
	</div>
{/each}

Done: {done} | Left: {remaining}

This step adds the biggest visual change in the whole tutorial, but the data model is still the same. Each todo still has text and done. We just added a little state for edit mode:

  • editingId tells us which row is currently being edited
  • editText holds the temporary input value while that row is open
  • editInput lets us focus that temporary input as soon as it appears

Then the row does one of two things:

  • If this todo is being edited, show an <input>
  • Otherwise, show a normal <span>

The strike-through is just a tiny style change:

<span style={todo.done ? 'text-decoration: line-through;' : ''}>
	{todo.text}
</span>

That keeps completed items visually different without changing the underlying data at all.

One small but useful detail is this line on the edit button:

onpointerdown={(e) => e.preventDefault()}

That stops the button click from blurring the input too early, which lets us use the same ✏️ button to both open edit mode and save the current edit cleanly.

We also focus the inline input right after edit mode opens. That part matters, because once the input actually has focus, clicking anywhere else blurs it, saves the text, and swaps the row back to normal text.

Step 3: persist the list with localStorage

At this point the app feels much better, but it still forgets everything on refresh. That’s probably the biggest remaining gap between a demo and something you’d actually use, so let’s fix that without changing the UI again.

We’ll read from localStorage inside onMount(), then use $effect() to save the list every time todos changes.

<script>
	import { onMount, tick } from 'svelte';

	const STORAGE_KEY = 'todos';
	let todos = $state([
		{ id: 1, text: 'Fly to the moon', done: false }
	]);
	let newTodo = $state('');
	let editingId = $state(null);
	let editText = $state('');
	let editInput = $state(null);
	let ready = $state(false);
	let nextId = 2;
	let done = $derived(todos.filter((t) => t.done).length);
	let remaining = $derived(todos.length - done);

	function addTodo(e) {
		e.preventDefault();

		const text = newTodo.trim();
		if (!text) return;

		todos.push({ id: nextId, text, done: false });
		nextId += 1;
		newTodo = '';
	}

	function removeTodo(id) {
		todos = todos.filter((todo) => todo.id !== id);
		if (editingId === id) {
			editingId = null;
			editText = '';
		}
	}

	async function focusEditInput() {
		await tick();
		if (!editInput) return;
		editInput.focus();
	}

	function handleEditButton(todo) {
		if (editingId === todo.id) {
			saveEdit();
			return;
		}

		if (editingId !== null) {
			saveEdit();
		}

		editingId = todo.id;
		editText = todo.text;
		void focusEditInput();
	}

	function saveEdit() {
		if (editingId === null) return;

		const text = editText.trim();
		const todo = todos.find((t) => t.id === editingId);

		if (todo && text) {
			todo.text = text;
		}

		editingId = null;
		editText = '';
	}

	onMount(() => {
		try {
			const saved = localStorage.getItem(STORAGE_KEY);
			if (saved) {
				const parsed = JSON.parse(saved);

				if (Array.isArray(parsed)) {
					todos = parsed;
					nextId = Math.max(0, ...todos.map((todo) => Number(todo.id) || 0)) + 1;
				}
			}
		} catch {
			localStorage.removeItem(STORAGE_KEY);
		} finally {
			ready = true;
		}
	});

	$effect(() => {
		if (!ready) return;
		localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
	});
</script>

<form onsubmit={addTodo}>
	<input bind:value={newTodo} placeholder="Build something fun" />
	<button>Add</button>
</form>

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

		{#if editingId === todo.id}
			<input bind:this={editInput} bind:value={editText} onblur={saveEdit} />
		{:else}
			<span style={todo.done ? 'text-decoration: line-through;' : ''}>
				{todo.text}
			</span>
		{/if}

		<button
			type="button"
			onpointerdown={(e) => e.preventDefault()}
			onclick={() => handleEditButton(todo)}
		>
			✏️
		</button>

		<button type="button" onclick={() => removeTodo(todo.id)}>

		</button>
	</div>
{/each}

Done: {done} | Left: {remaining}

The nice part here is that persistence automatically covers all the existing behavior too. If you edit a todo, toggle a checkbox, add a new item, or delete one, todos changes, and the save effect runs.

Because the inline input is focused as soon as it opens, clicking anywhere else triggers the blur handler too. So the edit flow still feels natural after we add persistence.

The ready flag matters because we do not want the save effect to write the default todo into storage before we’ve had a chance to load the real saved data.

Step 4: the full upgraded version

Here’s the finished version from this tutorial:

<script>
	import { onMount, tick } from 'svelte';

	const STORAGE_KEY = 'todos';
	let todos = $state([
		{ id: 1, text: 'Fly to the moon', done: false }
	]);
	let newTodo = $state('');
	let editingId = $state(null);
	let editText = $state('');
	let editInput = $state(null);
	let ready = $state(false);
	let nextId = 2;
	let done = $derived(todos.filter((t) => t.done).length);
	let remaining = $derived(todos.length - done);

	function addTodo(e) {
		e.preventDefault();

		const text = newTodo.trim();
		if (!text) return;

		todos.push({ id: nextId, text, done: false });
		nextId += 1;
		newTodo = '';
	}

	function removeTodo(id) {
		todos = todos.filter((todo) => todo.id !== id);
		if (editingId === id) {
			editingId = null;
			editText = '';
		}
	}

	async function focusEditInput() {
		await tick();
		if (!editInput) return;
		editInput.focus();
	}

	function handleEditButton(todo) {
		if (editingId === todo.id) {
			saveEdit();
			return;
		}

		if (editingId !== null) {
			saveEdit();
		}

		editingId = todo.id;
		editText = todo.text;
		void focusEditInput();
	}

	function saveEdit() {
		if (editingId === null) return;

		const text = editText.trim();
		const todo = todos.find((t) => t.id === editingId);

		if (todo && text) {
			todo.text = text;
		}

		editingId = null;
		editText = '';
	}

	onMount(() => {
		try {
			const saved = localStorage.getItem(STORAGE_KEY);
			if (saved) {
				const parsed = JSON.parse(saved);

				if (Array.isArray(parsed)) {
					todos = parsed;
					nextId = Math.max(0, ...todos.map((todo) => Number(todo.id) || 0)) + 1;
				}
			}
		} catch {
			localStorage.removeItem(STORAGE_KEY);
		} finally {
			ready = true;
		}
	});

	$effect(() => {
		if (!ready) return;
		localStorage.setItem(STORAGE_KEY, JSON.stringify(todos));
	});
</script>

<form onsubmit={addTodo}>
	<input bind:value={newTodo} placeholder="Build something fun" />
	<button>Add</button>
</form>

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

		{#if editingId === todo.id}
			<input bind:this={editInput} bind:value={editText} onblur={saveEdit} />
		{:else}
			<span style={todo.done ? 'text-decoration: line-through;' : ''}>
				{todo.text}
			</span>
		{/if}

		<button
			type="button"
			onpointerdown={(e) => e.preventDefault()}
			onclick={() => handleEditButton(todo)}
		>
			✏️
		</button>

		<button type="button" onclick={() => removeTodo(todo.id)}>

		</button>
	</div>
{/each}

Done: {done} | Left: {remaining}

Starting from the exact final code in the first tutorial, we added just a few things:

  • A proper add flow with newTodo
  • Trimmed input so blank todos do not get added
  • Stable ids for each item
  • Strike-through text for completed items
  • A little ✏️ edit button that swaps text into an inline input
  • Automatic focus so clicking anywhere else saves the inline edit
  • A red delete button
  • A keyed each block
  • Persistence with localStorage

Most importantly, we kept the original app’s feel. The checkbox still binds directly to todo.done. The counter is still derived from the same todos array. The edit flow is still inline inside the row.

That is the kind of growth I like in a tutorial series: same app, same core ideas, just a few carefully chosen improvements.

Try it yourself

Next step

The next tutorial starts from this exact finished version and mostly just moves the code into components so App.svelte is easier to scan: This Svelte todo app got messy. Let’s refactor it into components.