I spent two frustrating days debugging "why won't my component update" issues in Svelte 4 before Svelte 5 signals saved my sanity.
If you've ever stared at your screen wondering why your reactive statement isn't firing, or why your derived values are stale, you're in the right place.
What you'll build: A working todo app that demonstrates every signal pattern you need Time needed: 30 minutes of focused coding Difficulty: Beginner-friendly with real-world examples
Svelte 5 signals eliminate the guesswork around reactivity. No more $: statements that mysteriously don't trigger. No more wondering if your component will update. Just predictable, explicit state that works every single time.
Why I Switched to Signals
I was building a dashboard with nested components that needed to share state. My Svelte 4 approach was a mess of stores, reactive statements, and props drilling.
My setup:
- Dashboard with 8 interconnected widgets
- Real-time data updates from WebSocket
- Complex derived calculations based on user filters
What didn't work:
- Svelte stores felt overkill for simple component state
- Reactive statements
$:didn't always trigger when I expected - Debugging reactivity issues ate up entire afternoons
The final straw: a derived value that should have updated when the user changed filters, but only updated after I manually triggered a re-render. That's when I dove into Svelte 5 signals.
Understanding Signals vs Old Reactivity
The problem: Svelte 4's reactivity was implicit and sometimes unpredictable
My solution: Svelte 5 signals make reactivity explicit and foolproof
Time this saves: No more debugging mystery reactivity issues
What Are Signals?
Think of signals as smart variables that automatically notify anything that depends on them when they change.
// Old Svelte 4 way - implicit reactivity
let count = 0;
$: doubled = count * 2; // Sometimes this updates, sometimes it doesn't
// New Svelte 5 way - explicit signals
import { $state, $derived } from 'svelte';
let count = $state(0);
let doubled = $derived(() => count * 2); // This ALWAYS updates
What this does: Creates predictable, traceable reactivity chains Expected output: Components that update exactly when you expect them to
How signals create clear dependency tracking vs the old implicit system
Personal tip: "Signals feel weird at first if you're used to Svelte 4, but after one project you'll never want to go back"
Step 1: Set Up Your Svelte 5 Project
The problem: Svelte 5 is still in preview, so setup isn't straightforward
My solution: Use the official Svelte 5 template with the right configuration
Time this saves: Skips the "why isn't this working" debugging phase
# Create new Svelte 5 project
npm create svelte@latest my-signals-app
cd my-signals-app
# Install dependencies
npm install
# Start dev server
npm run dev
What this does: Creates a fresh Svelte 5 project with runes enabled Expected output: Dev server running on localhost:5173
Success looks like this - your dev server should start without errors
Personal tip: "Make sure you see 'Svelte 5' in your package.json version. If you see 4.x, the template gave you the wrong version"
Step 2: Your First Signal with $state
The problem: Simple reactive variables that actually stay in sync
My solution: Replace let declarations with $state() for reactive values
Time this saves: Eliminates "why didn't my component update" debugging
Create src/lib/Counter.svelte:
<script>
import { $state } from 'svelte';
// This is a signal - it automatically tracks dependencies
let count = $state(0);
function increment() {
count++; // This triggers any dependent updates automatically
}
function decrement() {
count--;
}
</script>
<div class="counter">
<h2>Count: {count}</h2>
<div class="buttons">
<button on:click={increment}>+</button>
<button on:click={decrement}>-</button>
</div>
</div>
<style>
.counter {
padding: 2rem;
text-align: center;
border: 2px solid #ddd;
border-radius: 8px;
margin: 1rem;
}
.buttons {
margin-top: 1rem;
}
button {
font-size: 1.5rem;
padding: 0.5rem 1rem;
margin: 0 0.5rem;
border: none;
border-radius: 4px;
background: #007acc;
color: white;
cursor: pointer;
}
button:hover {
background: #005999;
}
</style>
What this does: Creates a reactive counter that updates the UI automatically Expected output: Clicking buttons immediately updates the displayed count
Your counter should respond instantly to clicks - no lag, no missing updates
Personal tip: "The key difference from Svelte 4: you don't need $: reactive statements. The signal handles dependency tracking automatically"
Step 3: Derived Values with $derived
The problem: Calculated values that depend on other reactive values
My solution: Use $derived() for computed values that auto-update
Time this saves: No more manual dependency management
Add to your Counter.svelte:
<script>
import { $state, $derived } from 'svelte';
let count = $state(0);
// Derived values automatically recalculate when dependencies change
let doubled = $derived(() => count * 2);
let isEven = $derived(() => count % 2 === 0);
let message = $derived(() => {
if (count === 0) return "Start counting!";
if (count < 0) return "Going negative...";
if (count > 10) return "Double digits!";
return "Keep going!";
});
function increment() {
count++;
}
function decrement() {
count--;
}
</script>
<div class="counter">
<h2>Count: {count}</h2>
<p>Doubled: {doubled}</p>
<p>Is Even: {isEven ? 'Yes' : 'No'}</p>
<p class="message">{message}</p>
<div class="buttons">
<button on:click={increment}>+</button>
<button on:click={decrement}>-</button>
</div>
</div>
<style>
/* Previous styles... */
.message {
font-weight: bold;
color: #007acc;
margin: 1rem 0;
}
p {
margin: 0.5rem 0;
font-size: 1.1rem;
}
</style>
What this does: Creates computed values that automatically update when count changes Expected output: All derived values update instantly when you click the buttons
Every click updates count, doubled, isEven, and message simultaneously
Personal tip: "Derived values can depend on multiple signals. Svelte automatically tracks all dependencies - you don't have to list them manually like in other frameworks"
Step 4: Managing Complex State with $state Objects
The problem: Real apps need more than simple counters
My solution: Use $state() with objects for complex application state
Time this saves: Eliminates prop drilling and complex state management patterns
Create src/lib/TodoApp.svelte:
<script>
import { $state, $derived } from 'svelte';
// Complex state object - all properties are reactive
let todos = $state({
items: [],
filter: 'all', // 'all', 'active', 'completed'
newTodoText: ''
});
// Derived values for filtered lists and counts
let filteredTodos = $derived(() => {
switch (todos.filter) {
case 'active':
return todos.items.filter(todo => !todo.completed);
case 'completed':
return todos.items.filter(todo => todo.completed);
default:
return todos.items;
}
});
let activeCount = $derived(() =>
todos.items.filter(todo => !todo.completed).length
);
let completedCount = $derived(() =>
todos.items.filter(todo => todo.completed).length
);
function addTodo() {
if (todos.newTodoText.trim()) {
todos.items.push({
id: Date.now(),
text: todos.newTodoText.trim(),
completed: false
});
todos.newTodoText = '';
}
}
function toggleTodo(id) {
const todo = todos.items.find(t => t.id === id);
if (todo) {
todo.completed = !todo.completed;
}
}
function deleteTodo(id) {
const index = todos.items.findIndex(t => t.id === id);
if (index > -1) {
todos.items.splice(index, 1);
}
}
function setFilter(filter) {
todos.filter = filter;
}
</script>
<div class="todo-app">
<h1>Svelte 5 Signals Todo App</h1>
<!-- Add new todo -->
<div class="add-todo">
<input
bind:value={todos.newTodoText}
placeholder="What needs to be done?"
on:keydown={(e) => e.key === 'Enter' && addTodo()}
/>
<button on:click={addTodo}>Add</button>
</div>
<!-- Filter buttons -->
<div class="filters">
<button
class:active={todos.filter === 'all'}
on:click={() => setFilter('all')}
>
All ({todos.items.length})
</button>
<button
class:active={todos.filter === 'active'}
on:click={() => setFilter('active')}
>
Active ({activeCount})
</button>
<button
class:active={todos.filter === 'completed'}
on:click={() => setFilter('completed')}
>
Completed ({completedCount})
</button>
</div>
<!-- Todo list -->
<ul class="todo-list">
{#each filteredTodos as todo (todo.id)}
<li class="todo-item" class:completed={todo.completed}>
<input
type="checkbox"
checked={todo.completed}
on:change={() => toggleTodo(todo.id)}
/>
<span class="todo-text">{todo.text}</span>
<button
class="delete-btn"
on:click={() => deleteTodo(todo.id)}
>
✕
</button>
</li>
{/each}
</ul>
{#if todos.items.length === 0}
<p class="empty-state">No todos yet. Add one above!</p>
{/if}
</div>
<style>
.todo-app {
max-width: 600px;
margin: 2rem auto;
padding: 2rem;
border: 2px solid #ddd;
border-radius: 12px;
font-family: system-ui, sans-serif;
}
.add-todo {
display: flex;
gap: 1rem;
margin-bottom: 2rem;
}
.add-todo input {
flex: 1;
padding: 0.75rem;
border: 2px solid #ddd;
border-radius: 6px;
font-size: 1rem;
}
.add-todo button {
padding: 0.75rem 1.5rem;
background: #007acc;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
}
.filters {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
}
.filters button {
padding: 0.5rem 1rem;
border: 2px solid #ddd;
background: white;
border-radius: 6px;
cursor: pointer;
}
.filters button.active {
background: #007acc;
color: white;
border-color: #007acc;
}
.todo-list {
list-style: none;
padding: 0;
margin: 0;
}
.todo-item {
display: flex;
align-items: center;
gap: 1rem;
padding: 1rem;
border-bottom: 1px solid #eee;
}
.todo-item.completed .todo-text {
text-decoration: line-through;
color: #999;
}
.todo-text {
flex: 1;
font-size: 1.1rem;
}
.delete-btn {
background: #ff4444;
color: white;
border: none;
border-radius: 4px;
width: 30px;
height: 30px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.empty-state {
text-align: center;
color: #999;
font-style: italic;
margin: 2rem 0;
}
</style>
What this does: Creates a fully functional todo app with complex state management Expected output: A working todo app where all filters and counts update automatically
Every interaction updates the UI instantly - add, toggle, delete, filter all work perfectly
Personal tip: "Notice how I never had to write a single $: reactive statement. Signals handle all the dependency tracking automatically"
Step 5: Sharing State Between Components with $state
The problem: Passing complex state between sibling components
My solution: Create a shared state object and pass it down
Time this saves: Eliminates complex store setup for simple shared state
Update your src/routes/+page.svelte:
<script>
import { $state } from 'svelte';
import Counter from '$lib/Counter.svelte';
import TodoApp from '$lib/TodoApp.svelte';
// Shared application state
let appState = $state({
currentView: 'counter', // 'counter' or 'todos'
theme: 'light',
user: {
name: 'Demo User',
preferences: {
showCompleted: true
}
}
});
function switchView(view) {
appState.currentView = view;
}
function toggleTheme() {
appState.theme = appState.theme === 'light' ? 'dark' : 'light';
}
</script>
<main class="app" class:dark={appState.theme === 'dark'}>
<header>
<h1>Svelte 5 Signals Demo</h1>
<p>Welcome, {appState.user.name}!</p>
<nav>
<button
class:active={appState.currentView === 'counter'}
on:click={() => switchView('counter')}
>
Counter Demo
</button>
<button
class:active={appState.currentView === 'todos'}
on:click={() => switchView('todos')}
>
Todo App
</button>
<button on:click={toggleTheme}>
Theme: {appState.theme}
</button>
</nav>
</header>
<section class="content">
{#if appState.currentView === 'counter'}
<Counter />
{:else}
<TodoApp />
{/if}
</section>
</main>
<style>
.app {
min-height: 100vh;
background: white;
color: black;
transition: all 0.3s ease;
}
.app.dark {
background: #1a1a1a;
color: white;
}
header {
padding: 2rem;
text-align: center;
border-bottom: 2px solid #ddd;
}
.dark header {
border-bottom-color: #444;
}
nav {
margin-top: 1rem;
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
nav button {
padding: 0.75rem 1.5rem;
border: 2px solid #ddd;
background: white;
border-radius: 8px;
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
.dark nav button {
background: #333;
color: white;
border-color: #555;
}
nav button.active {
background: #007acc;
color: white;
border-color: #007acc;
}
nav button:hover {
transform: translateY(-2px);
}
.content {
padding: 2rem;
}
</style>
What this does: Creates a shared state system with theme switching and navigation Expected output: Smooth theme transitions and state that persists across view changes
Theme switching and navigation all powered by signals - notice how state persists when switching views
Personal tip: "This pattern replaces most use cases for Svelte stores. Only use stores when you need state to persist across page navigation"
Common Mistakes I Made (So You Don't Have To)
Mistake 1: Trying to Use Old Svelte 4 Patterns
// ❌ Don't do this in Svelte 5
let count = 0;
$: doubled = count * 2; // This won't work with runes enabled
// ✅ Do this instead
let count = $state(0);
let doubled = $derived(() => count * 2);
The fix: When you enable runes, old reactive statements stop working. Use signals consistently.
Mistake 2: Forgetting the Arrow Function in $derived
// ❌ This will throw an error
let doubled = $derived(count * 2);
// ✅ Always use arrow function
let doubled = $derived(() => count * 2);
The fix: $derived always needs a function that returns the computed value.
Mistake 3: Overcomplicating Simple State
// ❌ Overkill for simple component state
import { writable } from 'svelte/store';
const count = writable(0);
// ✅ Signals are perfect for this
let count = $state(0);
The fix: Use signals for component state, stores only when you need global state or persistence.
What You Just Built
You now have a complete understanding of Svelte 5's signal-based reactivity system. Your demo app includes:
- Simple reactive state with
$state() - Computed values with
$derived() - Complex object state management
- Component state sharing
- Real-world patterns you'll use in every project
Key Takeaways (Save These)
- Explicit is better than implicit: Signals make reactivity predictable and debuggable
- No more mystery updates: Every dependency is automatically tracked and triggers updates reliably
- Perfect for component state: Signals replace most needs for Svelte stores in simple apps
Your Next Steps
Pick one based on your experience:
- Beginner: Build a simple shopping cart using signals for item management
- Intermediate: Migrate an existing Svelte 4 component to use Svelte 5 signals
- Advanced: Explore
$effect()for side effects and API integration with signals
Tools I Actually Use
- Svelte 5 Preview: Latest version on npm - install with
@nexttag - VS Code Svelte Extension: Essential for syntax highlighting and IntelliSense with runes
- Svelte DevTools: Browser extension updated for Svelte 5 signal inspection
The signals mental model takes about one project to click, but once it does, you'll never want to go back to the old way of managing state in Svelte.