DEV Community

Cover image for Loops in Svelte — {#each}, Keys, and Building a Todo App
Ali Aslam
Ali Aslam

Posted on

Loops in Svelte — {#each}, Keys, and Building a Todo App

Quick question: when was the last time you saw an app that only showed one thing?

  • One todo in your todo app.
  • One email in your inbox.
  • One meme in your feed.

That would be… the saddest app ever. 😂

Real apps deal with lists. Dozens, hundreds, sometimes thousands of things. And we don’t want to copy-paste the same <div> a hundred times — we want the computer to do the boring work for us.

That’s where Svelte loops come in. They’re like a copy machine 📠: you design one template, then Svelte stamps out as many copies as you need.

In this chapter, we’ll learn how to:

  1. Repeat elements with {#each}.
  2. Pull out properties cleanly with destructuring.
  3. Use indices when you need “first, second, third.”
  4. Handle empty arrays gracefully.
  5. Nest loops for categories and sub-items.

By the end, you’ll be ready to render everything from a tiny todo list ✅ to a giant shopping cart 🛒 — all without breaking a sweat.


The {#each} Block

The simplest loop in Svelte is {#each}. Let’s render a list of fruits.

📂 src/lib/FruitList.svelte

<script>
  let fruits = $state(['Apple', 'Banana', 'Cherry']);
</script>

<ul>
  {#each fruits as fruit}
    <li>{fruit}</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import FruitList from '$lib/FruitList.svelte';
</script>

<FruitList />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🍌

  • let fruits = $state([...])
    A reactive array of fruit names.

  • {#each fruits as fruit}
    Loops over each item, one by one. For each, we call it fruit.

  • <li>{fruit}</li>
    Displays the fruit inside a list item.

👉 Result: an unordered list with Apple, Banana, Cherry.

Analogy: Think of {#each} like a cookie cutter 🍪. The dough is your array, and each cookie is an element produced in the DOM. Same shape, different piece.


Destructuring Inside Loops

Arrays don’t always hold just strings. Sometimes they hold objects with multiple properties. {#each} can destructure them directly.

📂 src/lib/TodoList.svelte

<script>
  let todos = $state([
    { id: 1, text: 'Learn Svelte', done: false },
    { id: 2, text: 'Build an app', done: true },
    { id: 3, text: 'Celebrate 🎉', done: false }
  ]);
</script>

<ul>
  {#each todos as { text, done }}
    <li>
      {text} {done ? '' : ''}
    </li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import TodoList from '$lib/TodoList.svelte';
</script>

<TodoList />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 📝

  • todos is an array of objects. Each has id, text, and done.
  • {#each todos as { text, done }} This is destructuring! Instead of writing todo.text and todo.done, we unpack them into local variables.
  • <li>{text} {done ? '✅' : '❌'}</li> Shows each task with a checkmark or cross.

👉 Result: You get a neat todo list. And notice how clean the code looks — no extra todo. prefix everywhere.


Using the Index

Sometimes you need to know which number in the list you’re on (0, 1, 2…). You can grab that with , i.

📂 src/lib/NumberedList.svelte

<script>
  let items = $state(['First', 'Second', 'Third']);
</script>

<ol>
  {#each items as item, i}
    <li>{i + 1}. {item}</li>
  {/each}
</ol>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import NumberedList from '$lib/NumberedList.svelte';
</script>

<NumberedList />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🔢

  • {#each items as item, i} The second variable is the index (starting at 0).
  • <li>{i + 1}. {item}</li> Adds numbering before each item.

👉 Result:

  1. First
  2. Second
  3. Third

Handling Empty Lists

What if the array is empty? By default, nothing renders. Sometimes that’s fine, but often you want to show a message like “No items yet.”

Svelte gives us an {:else} inside {#each}.

📂 src/lib/EmptyList.svelte

<script>
  let books = $state([]);
</script>

<ul>
  {#each books as book}
    <li>{book}</li>
  {:else}
    <li>No books found.</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import EmptyList from '$lib/EmptyList.svelte';
</script>

<EmptyList />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 📚

  • let books = $state([]);
    Starts empty.

  • {#each books as book} … {:else} … {/each}
    If there are books, render them. Otherwise, render the {:else} branch.

👉 Result: the list shows “No books found.” until you add something to the array.

Analogy: It’s like opening your fridge 🧊. If it’s full, you see food. If it’s empty, you don’t just see nothing — you see “No groceries.”


Nested Loops

Loops can even be nested — one inside another. For example, let’s render categories with their items.

📂 src/lib/CategoryList.svelte

<script>
  let categories = $state([
    { name: 'Fruits', items: ['Apple', 'Banana'] },
    { name: 'Vegetables', items: ['Carrot', 'Lettuce'] }
  ]);
</script>

<div>
  {#each categories as category}
    <h2>{category.name}</h2>
    <ul>
      {#each category.items as item}
        <li>{item}</li>
      {/each}
    </ul>
  {/each}
</div>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import CategoryList from '$lib/CategoryList.svelte';
</script>

<CategoryList />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🥗

  • categories is an array of objects. Each object has a name and an array of items.
  • Outer {#each} → loops through categories.
  • Inner {#each} → loops through items inside each category.
  • <h2> displays category names, <li> lists the items.

👉 Result:
Fruits

  • Apple
  • Banana

Vegetables

  • Carrot
  • Lettuce

Analogy: It’s like a grocery store 🛒. First you see the aisle signs (Fruits, Vegetables), then inside each aisle, you see the items.

⚠️ Warning: Deeply nested loops can get hard to read. Just like with nested conditionals, if things start looking tangled, consider splitting into smaller components.


Reactivity Reminders

With $state, arrays and objects are reactive even when you mutate them. That means things like push, pop, splice, and updating object properties will trigger the UI to update. No extra tricks required.

Let’s see it:

📂 src/lib/ReactiveTodos.svelte

<script>
  let todos = $state(['Learn Svelte', 'Build an app']);
  let newTodo = $state('');

  function addTodo() {
    const v = newTodo.trim();
    if (!v) return;

    // ✅ This works with $state: mutation is reactive
    todos.push(v);

    // (Optional alternative — also works)
    // todos = [...todos, v];

    newTodo = '';
  }
</script>

<input
  placeholder="New todo"
  bind:value={newTodo}
/>

<button on:click={addTodo}>Add</button>

<ul>
  {#each todos as todo}
    <li>{todo}</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import ReactiveTodos from '$lib/ReactiveTodos.svelte';
</script>

<ReactiveTodos />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🪄

  • let todos = $state([...]) makes the array reactive.
  • todos.push(v) is enough to update the UI. You’ll see the new item appear instantly.
  • Reassigning (todos = [...todos, v]) also works. Some teams prefer this style for code clarity or immutability patterns, but it’s not required for reactivity when you’re using $state.

Try it yourself:

  • Add a few todos and watch them render immediately.
  • Swap todos.push(v) with the reassignment line and confirm both approaches behave the same.
  • Bonus: experiment with removal via todos.splice(i, 1)—the UI will react to that mutation too.

Bonus: objects mutate reactively too

📂 src/lib/ReactionDemo.svelte

<script>
  let person = $state({ name: 'Ada', likes: 0 });
</script>

<p>{person.name} likes: {person.likes}</p>
<button on:click={() => person.likes++}>Like</button>
Enter fullscreen mode Exit fullscreen mode
  • Clicking Like increments person.likes and the <p> updates immediately. No reassignment needed.

When might reassignment still be useful?

  • Code style/clarity: Some devs like immutability (always creating new arrays/objects) because it’s predictable and plays nicely with certain patterns (e.g., undo/redo).
  • Transformations: When you already have a new array/object (e.g., from map, filter, reduce), reassigning is natural.
  • Signals to readers: A reassignment can make it visually obvious “something changed” when scanning code.

Styling Lists

Lists don’t have to look boring. You can use conditionals and styling inside loops to make each item visually reflect its state.

📂 src/lib/StyledTodos.svelte

<script>
  let todos = $state([
    { text: 'Write code', done: true },
    { text: 'Test app', done: false },
    { text: 'Ship product', done: false }
  ]);
</script>

<ul>
  {#each todos as { text, done }}
    <li style="color: {done ? 'green' : 'red'}">
      {done ? '' : ''} {text}
    </li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🎨

  • todos is now an array of objects.
  • We destructure { text, done } right in the loop for clarity.
  • If done is true → show ✅ and turn text green.
  • If not → show ❌ and turn text red.

👉 The list instantly communicates status: finished tasks in green, pending ones in red.

Analogy: Think of it like a teacher’s gradebook 📒. Passed students are highlighted in green, those who still owe homework are marked in red.

Try it yourself:
Change one of the todos’ done values to true or false and watch the colors flip.


Keys Matter

This is a big one. When you render a list that changes order or has items added/removed, Svelte needs to know which DOM node corresponds to which item. That’s why {#each} supports keys.

📂 src/lib/KeyedList.svelte

<script>
  let people = $state([
    { id: 1, name: 'Ada' },
    { id: 2, name: 'Grace' },
    { id: 3, name: 'Linus' }
  ]);

  function shuffle() {
    people = people.sort(() => Math.random() - 0.5);
  }
</script>

<button on:click={shuffle}>Shuffle</button>

<ul>
  {#each people as person (person.id)}
    <li>{person.name}</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🧩

  • The important part is (person.id). That tells Svelte: “Use id as the stable identity for this item.”
  • Without keys, if you shuffle the array, Svelte might reuse DOM nodes incorrectly — leading to weird glitches (like the wrong person keeping focus in an input).
  • With keys, each list item is “tagged” with its ID, so Svelte moves the right DOM nodes around.

Analogy: Think of a classroom seating chart 🪑. If students shuffle around but keep their name tags, the teacher still knows who’s who. Without name tags, chaos ensues.

Try it yourself:

  • Remove (person.id) and try shuffling. The UI still seems okay because the list is simple. But with more complex UIs (like inputs or animations), things will go wrong.
  • Put the keys back and breathe easy.

Performance Notes

Loops might make you worry: “What if I have thousands of items? Won’t this be slow?”

The good news: Svelte compiles loops into super-efficient code. It only updates what actually changed.

👉 For most apps: don’t worry.
👉 For really large lists (tens of thousands of rows): you might look into virtualization libraries (which only render what’s visible). That’s an advanced optimization.

Analogy: Imagine updating a giant spreadsheet 📊. Instead of rewriting the whole sheet, Svelte just changes the few cells that actually updated.

Rule of thumb: write clear loops, add keys where needed, and let Svelte handle the rest.


Mini-Project — Shopping Cart 🛒

Let’s put it all together into something more real: a shopping cart. This will use reactivity, indices, keys, empty fallbacks, and array updates.

📂 src/lib/ShoppingCart.svelte

<script>
  let cart = $state([
    { id: 1, name: 'Apple', qty: 2 },
    { id: 2, name: 'Banana', qty: 1 }
  ]);
  let newItem = $state('');
</script>

<input
  placeholder="New item"
  bind:value={newItem}
/>
<button
  on:click={() => {
    if (newItem.trim()) {
      const id = Date.now();
      cart = [...cart, { id, name: newItem, qty: 1 }];
      newItem = '';
    }
  }}
>
  Add
</button>

<ul>
  {#each cart as item, i (item.id)}
    <li>
      {i + 1}. {item.name} (x{item.qty})
      <button on:click={() => {
        cart = cart.map(it =>
          it.id === item.id ? { ...it, qty: it.qty + 1 } : it
        );
      }}>+</button>
      <button on:click={() => {
        cart = cart.filter(it => it.id !== item.id);
      }}>Remove</button>
    </li>
  {:else}
    <li>Cart is empty.</li>
  {/each}
</ul>
Enter fullscreen mode Exit fullscreen mode

📂 src/routes/+page.svelte

<script>
  import ShoppingCart from '$lib/ShoppingCart.svelte';
</script>

<h1>My Cart</h1>
<ShoppingCart />
Enter fullscreen mode Exit fullscreen mode

Breaking it down 🛍️

  • cart starts with two items. Each has id, name, and qty.
  • Adding an item: creates a new object with a fresh id (using Date.now() as a simple unique key).
  • Each row shows the item’s index, name, and quantity.
  • The + button increments quantity → we use .map() and reassign cart.
  • The Remove button deletes the item → we use .filter() and reassign cart.
  • The {:else} ensures that if the cart is empty, the UI says so.

👉 This tiny shopping cart ties all the loop features together: iteration, indices, keys, reactivity, and empty handling.

Analogy: It’s like managing a real basket at the store 🛒. Add things, increase amounts, remove them. The basket always shows what’s inside, and if it’s empty, it tells you so.

Try it yourself:

  • Add “Orange” → it appears with quantity 1.
  • Click + → it bumps to 2.
  • Remove Banana → gone.
  • Remove everything → “Cart is empty.”

Wrapping Up 🎁

We’ve covered a lot in this journey through loops. Let’s summarize:

  • {#each} → repeat a block for each item.
  • Destructuring → makes code cleaner.
  • Indices, i gives you the position.
  • Empty lists → add {:else} so the UI isn’t blank.
  • Nested loops → useful, but don’t overdo it.
  • Reactivity → with $state, both mutation and reassignment are reactive; pick the one that’s clearest for your codebase.
  • Keys → crucial for stable updates when items move or change.
  • Performance → Svelte is efficient, you usually don’t need to worry.

👉 Big picture: loops + conditionals = dynamic UIs that respond to any amount of data.

At this point, your Svelte toolkit covers:

  • Props (passing data down).
  • Events (sending messages up).
  • Conditionals (making decisions).
  • Loops (handling collections).

That’s a solid foundation for building almost anything. 🚀

Next up in this series, we’ll build on these fundamentals to tackle more advanced patterns — but for now, give yourself a pat on the back. You’ve gone from rendering one thing to rendering entire collections like a pro. 🎉

Next article: Styling in Svelte (Scoped CSS, :global, and Class Directives)


Follow me on DEV for future posts in this deep-dive series.
https://dev.to/a1guy
If it helped, leave a reaction (heart / bookmark) — it keeps me motivated to create more content
Checkout my offering on YouTube with (growing) crash courses and content on JavaScript, React, TypeScript, Rust, WebAssembly, AI Prompt Engineering and more: @LearnAwesome

Top comments (0)