Skip to main content

This Svelte todo app got messy. Let's refactor it into components.

Updated on March 14, 2026

Written by MaDrCloudDev

Let's take the finished app from the previous tutorial and split it into components without changing behavior.

An illustration of a larger todo app being split into neat Svelte components for a form, list, and item row.

In the previous post, we took the tiny todo app and made it more useful without changing its basic shape. By the end, though, all of that code still lived in one file.

In this tutorial, we’re going to keep that exact same behavior and mostly just move code into better homes. No new features. No new interaction model. Just the same app, split into smaller pieces so it is easier to scan.

Step 0: exact starting point from the previous tutorial

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

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

That is our baseline. Everything below is just a refactor of this same app.

Step 1: extract the add form

The form is the easiest thing to move first because it already has a clean boundary. It only needs two things from App.svelte:

  • newTodo
  • addTodo

Create TodoForm.svelte:

<script>
	let { newTodo = $bindable(), addTodo } = $props();
</script>

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

Then update App.svelte:

<script>
	import { onMount, tick } from 'svelte';
	import TodoForm from './TodoForm.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>

<TodoForm bind:newTodo {addTodo} />

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

No behavior changed here. newTodo still lives in App.svelte, and addTodo() is still the same function. We just moved the form markup into its own component and used bind:newTodo so the child input can still update the parent’s state.

Step 2: extract the list and each row

Now let’s move the list markup out too. We’ll keep the actual app logic in App.svelte, especially persistence and the add/edit/delete functions. That also includes the little bit of focus handling that makes blur-save work when you click anywhere else.

Create TodoItem.svelte:

<script>
	let {
		todo,
		isEditing,
		editText = $bindable(),
		editInput = $bindable(),
		handleEditButton,
		saveEdit,
		removeTodo
	} = $props();
</script>

<div>
	<input type="checkbox" bind:checked={todo.done} />

	{#if isEditing}
		<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>

Create TodoList.svelte:

<script>
	import TodoItem from './TodoItem.svelte';

	let {
		todos,
		editingId,
		editText = $bindable(),
		editInput = $bindable(),
		handleEditButton,
		saveEdit,
		removeTodo
	} = $props();
</script>

{#each todos as todo (todo.id)}
	<TodoItem
		{todo}
		isEditing={editingId === todo.id}
		bind:editText
		bind:editInput
		{handleEditButton}
		{saveEdit}
		{removeTodo}
	/>
{/each}

Then App.svelte becomes:

<script>
	import { onMount, tick } from 'svelte';
	import TodoForm from './TodoForm.svelte';
	import TodoList from './TodoList.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>

<TodoForm bind:newTodo {addTodo} />
<TodoList
	{todos}
	{editingId}
	bind:editText
	bind:editInput
	{handleEditButton}
	{saveEdit}
	{removeTodo}
/>

Done: {done} | Left: {remaining}

This is the kind of refactor we want. The app still works the same, but App.svelte no longer has to hold the form markup and every row of the list.

Step 3: the full refactored version

Here is the final structure:

  • App.svelte keeps app-level state, persistence, and counters
  • TodoForm.svelte holds the add form
  • TodoList.svelte holds the loop
  • TodoItem.svelte holds one todo row

App.svelte

<script>
	import { onMount, tick } from 'svelte';
	import TodoForm from './TodoForm.svelte';
	import TodoList from './TodoList.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>

<TodoForm bind:newTodo {addTodo} />
<TodoList
	{todos}
	{editingId}
	bind:editText
	bind:editInput
	{handleEditButton}
	{saveEdit}
	{removeTodo}
/>

Done: {done} | Left: {remaining}

TodoForm.svelte

<script>
	let { newTodo = $bindable(), addTodo } = $props();
</script>

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

TodoList.svelte

<script>
	import TodoItem from './TodoItem.svelte';

	let {
		todos,
		editingId,
		editText = $bindable(),
		editInput = $bindable(),
		handleEditButton,
		saveEdit,
		removeTodo
	} = $props();
</script>

{#each todos as todo (todo.id)}
	<TodoItem
		{todo}
		isEditing={editingId === todo.id}
		bind:editText
		bind:editInput
		{handleEditButton}
		{saveEdit}
		{removeTodo}
	/>
{/each}

TodoItem.svelte

<script>
	let {
		todo,
		isEditing,
		editText = $bindable(),
		editInput = $bindable(),
		handleEditButton,
		saveEdit,
		removeTodo
	} = $props();
</script>

<div>
	<input type="checkbox" bind:checked={todo.done} />

	{#if isEditing}
		<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>

This version stays very close to tutorial 2. We did not introduce new behavior, and we did not invent extra state just to justify the refactor. We mostly moved existing code into smaller files, including the little focus handoff that makes inline edits save when you click anywhere else, which is exactly why the result feels cleaner.

Try it yourself