Stop Pulling Your Hair Out — Let's Debug Your Signals Together
Ever stared at your Angular code for 20 minutes wondering why your Signal isn't updating? You're not alone. Last week, I spent an embarrassing amount of time debugging a Signal that refused to update — turns out I was mutating an array instead of creating a new one. Classic mistake, right?
Here's the thing: Angular Signals are incredibly powerful for managing reactive state, but they come with gotchas that can drive even experienced developers crazy. The good news? Once you understand why Signals sometimes refuse to update, you'll never get stuck on these issues again.
By the end of this article, you'll:
- ✅ Understand the 5 most common reasons Signals fail to update
- ✅ Know exactly how to debug Signal issues like a pro
- ✅ Have battle-tested patterns to avoid these problems entirely
- ✅ Get working code examples you can copy-paste right now
Quick question before we dive in: Have you ever had a Signal that just wouldn't update no matter what you tried? Drop a comment below with your weirdest Signal bug — I bet we've all been there! 💬
The Big Problem: Why Your Signals Aren't Updating
Let's start with the uncomfortable truth: 90% of Signal update issues come from treating them like regular variables. Signals need special care, and the Angular reactivity system has specific rules we need to follow.
Think of Signals like a notification system — they only notify subscribers when they detect a real change. And here's where things get tricky: what you consider a change might not be what Angular considers a change.
Let me show you exactly what I mean with real code...
🐛 Common Reason #1: Mutating Objects/Arrays Instead of Replacing Them
This is the number one killer of Signal updates. Let's look at a typical bug:
The Buggy Code:
import { signal } from '@angular/core';
export class TodoComponent {
todos = signal<Todo[]>([]);
addTodo(newTodo: Todo) {
// ❌ This WON'T trigger updates!
this.todos().push(newTodo);
}
removeTodo(index: number) {
// ❌ Also won't work!
this.todos().splice(index, 1);
}
}
Why doesn't this work? You're mutating the existing array. The Signal still points to the same array reference, so Angular thinks nothing changed!
The Fix:
import { signal } from '@angular/core';
export class TodoComponent {
todos = signal<Todo[]>([]);
addTodo(newTodo: Todo) {
// ✅ Create a new array reference
this.todos.update(currentTodos => [...currentTodos, newTodo]);
}
removeTodo(index: number) {
// ✅ Filter creates a new array
this.todos.update(currentTodos =>
currentTodos.filter((_, i) => i !== index)
);
}
// Alternative using set()
addTodoAlt(newTodo: Todo) {
// ✅ Also works!
this.todos.set([...this.todos(), newTodo]);
}
}
Pro tip: Always think "immutable updates" when working with Signals. If you're using methods like push()
, pop()
, splice()
, or directly modifying object properties, you're probably doing it wrong!
🐛 Common Reason #2: Misusing set(), update(), and mutate()
Angular gives us three ways to update Signals, and using the wrong one can cause issues:
Understanding the Methods:
import { signal } from '@angular/core';
export class UserProfileComponent {
// For objects
userProfile = signal({
name: 'John',
age: 30,
preferences: {
theme: 'dark',
notifications: true
}
});
// ❌ WRONG: This won't trigger updates for nested properties
updateThemeWrong() {
const profile = this.userProfile();
profile.preferences.theme = 'light'; // Direct mutation!
}
// ✅ CORRECT: Using update() with spread operator
updateThemeCorrect() {
this.userProfile.update(profile => ({
...profile,
preferences: {
...profile.preferences,
theme: 'light'
}
}));
}
// ✅ CORRECT: Using set() with a new object
updateThemeWithSet() {
const currentProfile = this.userProfile();
this.userProfile.set({
...currentProfile,
preferences: {
...currentProfile.preferences,
theme: 'light'
}
});
}
// ✅ CORRECT: Using mutate() for deliberate mutation
updateThemeWithMutate() {
this.userProfile.mutate(profile => {
profile.preferences.theme = 'light';
});
}
}
When to use each:
-
set()
: When you have the complete new value ready -
update()
: When you need to compute the new value based on the current one -
mutate()
: When you specifically want to mutate (use sparingly!)
🐛 Common Reason #3: Computed Signals with Side Effects
Here's a sneaky one that catches many developers:
The Buggy Code:
export class CartComponent {
items = signal<CartItem[]>([]);
// ❌ WRONG: Side effects in computed!
totalPrice = computed(() => {
const total = this.items().reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
// This is a side effect - DON'T do this!
console.log('Total calculated:', total);
this.saveToLocalStorage(total); // Another side effect!
return total;
});
}
The Fix:
export class CartComponent {
items = signal<CartItem[]>([]);
// ✅ CORRECT: Pure computed signal
totalPrice = computed(() => {
return this.items().reduce((sum, item) =>
sum + item.price * item.quantity, 0
);
});
// Use effect for side effects
constructor() {
effect(() => {
const total = this.totalPrice();
console.log('Total calculated:', total);
this.saveToLocalStorage(total);
});
}
}
Remember: Computed signals must be pure functions — no side effects allowed!
🐛 Common Reason #4: Missing Dependencies in Effects
Effects can be tricky when you're not explicitly tracking all dependencies:
The Buggy Code:
export class NotificationComponent {
userId = signal<number>(1);
notifications = signal<Notification[]>([]);
constructor() {
// ❌ This won't re-run when userId changes!
const id = this.userId();
effect(() => {
// userId is read outside the effect
this.loadNotifications(id);
});
}
}
The Fix:
export class NotificationComponent {
userId = signal<number>(1);
notifications = signal<Notification[]>([]);
constructor() {
// ✅ CORRECT: Read signals inside the effect
effect(() => {
const id = this.userId(); // Read inside effect!
this.loadNotifications(id);
});
}
}
Golden rule: Always read Signal values inside the effect function to ensure proper dependency tracking.
🐛 Common Reason #5: Async Updates Not Handled Properly
This one's a classic — async operations need special handling:
The Buggy Code:
export class DataComponent {
data = signal<any[]>([]);
async loadData() {
// ❌ Signal might not update as expected
this.data().push(...await this.apiService.getData());
}
}
The Fix:
export class DataComponent {
data = signal<any[]>([]);
loading = signal<boolean>(false);
async loadData() {
this.loading.set(true);
try {
// ✅ CORRECT: Create new array with async data
const newData = await this.apiService.getData();
this.data.update(current => [...current, ...newData]);
} finally {
this.loading.set(false);
}
}
// Even better with proper error handling
async loadDataWithError() {
this.loading.set(true);
try {
const response = await this.apiService.getData();
this.data.set(response); // Replace entirely
} catch (error) {
console.error('Failed to load data:', error);
// Handle error appropriately
} finally {
this.loading.set(false);
}
}
}
Quick check: Which of these issues have you run into? I'm curious — drop a comment with the number (1-5) that's bitten you the most! 👇
🔍 Debugging Signals Like a Pro
When things aren't working, here's your debugging toolkit:
1. Effect Logging Pattern
export class DebugComponent {
mySignal = signal({ count: 0, name: 'test' });
constructor() {
// Debug effect - remove in production!
effect(() => {
console.log('🔄 Signal updated:', {
value: this.mySignal(),
timestamp: new Date().toISOString()
});
});
}
}
2. Custom Debug Wrapper
// Create a debug signal wrapper
function debugSignal<T>(initialValue: T, name: string) {
const sig = signal(initialValue);
// Wrap the update method
const originalUpdate = sig.update.bind(sig);
sig.update = (updateFn: (value: T) => T) => {
const oldValue = sig();
const result = originalUpdate(updateFn);
const newValue = sig();
console.log(`📊 ${name} updated:`, { oldValue, newValue });
return result;
};
return sig;
}
// Usage
export class MyComponent {
todos = debugSignal<Todo[]>([], 'todos');
}
3. Angular DevTools Integration
export class DevToolsComponent {
// Make signals visible in DevTools
readonly debugState = computed(() => ({
todos: this.todos(),
filter: this.filter(),
filtered: this.filteredTodos(),
timestamp: Date.now()
}));
}
📝 Real-World Example: Todo List with Common Pitfalls
Let's put it all together with a real example that shows both problems and solutions:
import { Component, signal, computed, effect } from '@angular/core';
interface Todo {
id: number;
text: string;
completed: boolean;
createdAt: Date;
}
@Component({
selector: 'app-todo-list',
template: `
<div class="todo-container">
<input #todoInput (keyup.enter)="addTodo(todoInput.value); todoInput.value=''" />
<div class="filters">
<button (click)="filter.set('all')"
[class.active]="filter() === 'all'">All</button>
<button (click)="filter.set('active')"
[class.active]="filter() === 'active'">Active</button>
<button (click)="filter.set('completed')"
[class.active]="filter() === 'completed'">Completed</button>
</div>
<ul>
@for (todo of filteredTodos(); track todo.id) {
<li [class.completed]="todo.completed">
<input type="checkbox"
[checked]="todo.completed"
(change)="toggleTodo(todo.id)">
<span>{{ todo.text }}</span>
<button (click)="removeTodo(todo.id)">❌</button>
</li>
}
</ul>
<div class="stats">
Active: {{ stats().active }} |
Completed: {{ stats().completed }} |
Total: {{ stats().total }}
</div>
</div>
`
})
export class TodoListComponent {
// Signals for state
todos = signal<Todo[]>([]);
filter = signal<'all' | 'active' | 'completed'>('all');
nextId = signal(1);
// Computed signals (pure, no side effects!)
filteredTodos = computed(() => {
const currentFilter = this.filter();
const allTodos = this.todos();
switch (currentFilter) {
case 'active':
return allTodos.filter(t => !t.completed);
case 'completed':
return allTodos.filter(t => t.completed);
default:
return allTodos;
}
});
stats = computed(() => {
const allTodos = this.todos();
return {
total: allTodos.length,
active: allTodos.filter(t => !t.completed).length,
completed: allTodos.filter(t => t.completed).length
};
});
constructor() {
// Effect for side effects (localStorage persistence)
effect(() => {
const todosToSave = this.todos();
if (todosToSave.length > 0) {
localStorage.setItem('todos', JSON.stringify(todosToSave));
}
});
// Load initial data
this.loadFromStorage();
}
addTodo(text: string) {
if (!text.trim()) return;
const newTodo: Todo = {
id: this.nextId(),
text: text.trim(),
completed: false,
createdAt: new Date()
};
// ✅ CORRECT: Using update to create new array
this.todos.update(current => [...current, newTodo]);
this.nextId.update(id => id + 1);
}
toggleTodo(id: number) {
// ✅ CORRECT: Creating new array with updated object
this.todos.update(todos =>
todos.map(todo =>
todo.id === id
? { ...todo, completed: !todo.completed }
: todo
)
);
}
removeTodo(id: number) {
// ✅ CORRECT: Filter creates new array
this.todos.update(todos => todos.filter(t => t.id !== id));
}
private loadFromStorage() {
const stored = localStorage.getItem('todos');
if (stored) {
try {
const parsed = JSON.parse(stored);
this.todos.set(parsed);
const maxId = Math.max(...parsed.map((t: Todo) => t.id), 0);
this.nextId.set(maxId + 1);
} catch (e) {
console.error('Failed to load todos:', e);
}
}
}
}
🧪 Unit Testing Your Signals
Don't forget to test! Here's how to properly test Signal-based components:
import { TestBed } from '@angular/core/testing';
import { signal, effect } from '@angular/core';
describe('TodoListComponent', () => {
let component: TodoListComponent;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [TodoListComponent]
});
component = TestBed.createComponent(TodoListComponent).componentInstance;
});
it('should add a new todo', () => {
const initialCount = component.todos().length;
component.addTodo('Test todo');
expect(component.todos().length).toBe(initialCount + 1);
expect(component.todos()[initialCount].text).toBe('Test todo');
});
it('should filter todos correctly', () => {
// Setup
component.todos.set([
{ id: 1, text: 'Active', completed: false, createdAt: new Date() },
{ id: 2, text: 'Completed', completed: true, createdAt: new Date() }
]);
// Test active filter
component.filter.set('active');
expect(component.filteredTodos().length).toBe(1);
expect(component.filteredTodos()[0].text).toBe('Active');
// Test completed filter
component.filter.set('completed');
expect(component.filteredTodos().length).toBe(1);
expect(component.filteredTodos()[0].text).toBe('Completed');
});
it('should update stats when todos change', () => {
component.todos.set([
{ id: 1, text: 'Task 1', completed: false, createdAt: new Date() },
{ id: 2, text: 'Task 2', completed: true, createdAt: new Date() },
{ id: 3, text: 'Task 3', completed: false, createdAt: new Date() }
]);
const stats = component.stats();
expect(stats.total).toBe(3);
expect(stats.active).toBe(2);
expect(stats.completed).toBe(1);
});
it('should handle async operations correctly', (done) => {
// Mock async operation
spyOn(component, 'loadFromStorage').and.callFake(async () => {
await new Promise(resolve => setTimeout(resolve, 100));
component.todos.set([
{ id: 1, text: 'Loaded', completed: false, createdAt: new Date() }
]);
});
component.loadFromStorage().then(() => {
expect(component.todos().length).toBe(1);
expect(component.todos()[0].text).toBe('Loaded');
done();
});
});
});
✨ Best Practices to Avoid Signal Update Issues
After debugging countless Signal issues, here are my battle-tested best practices:
1. Always Use Immutable Updates
// ✅ DO THIS
signal.update(arr => [...arr, newItem]);
signal.update(obj => ({ ...obj, newProp: value }));
// ❌ NOT THIS
signal().push(newItem);
signal().newProp = value;
2. Keep Computed Signals Pure
// ✅ DO THIS
totalPrice = computed(() =>
this.items().reduce((sum, item) => sum + item.price, 0)
);
// ❌ NOT THIS
totalPrice = computed(() => {
const total = this.items().reduce((sum, item) => sum + item.price, 0);
this.saveToDatabase(total); // Side effect!
return total;
});
3. Use Effects Wisely for Debugging
// Development debugging
constructor() {
if (!environment.production) {
effect(() => {
console.log('State changed:', {
todos: this.todos(),
filter: this.filter()
});
});
}
}
4. Create Helper Functions for Complex Updates
// Instead of complex inline updates
private updateNestedProperty<T>(
signal: WritableSignal<T>,
path: string[],
value: any
): void {
signal.update(current => {
const updated = JSON.parse(JSON.stringify(current)); // Deep clone
let ref = updated;
for (let i = 0; i < path.length - 1; i++) {
ref = ref[path[i]];
}
ref[path[path.length - 1]] = value;
return updated;
});
}
5. Document Your Signal Patterns
/**
* Cart state management using Signals
* - items: Array of cart items (immutable updates only!)
* - total: Computed from items (pure function)
* - Effects: Persist to localStorage, sync with backend
*/
export class CartService {
// ... your code
}
💡 Bonus Tips
Here are some extra nuggets of wisdom I've learned the hard way:
-
Use Signal Inputs for Components (Angular 17.1+):
export class ProductCard { // Instead of @Input() product = input.required<Product>(); quantity = input(1); }
-
Batch Updates for Performance:
// Multiple updates in one go batch(() => { this.todos.update(/* ... */); this.filter.set('all'); this.loading.set(false); });
-
Create Custom Signal Factories:
function createAsyncSignal<T>(initialValue: T) { const data = signal(initialValue); const loading = signal(false); const error = signal<Error | null>(null); return { data, loading, error }; }
🎯 Recap: Your Signal Debugging Checklist
Let's wrap this up with a quick checklist you can bookmark:
✅ Not updating? Check if you're mutating instead of replacing
✅ Using update/set correctly? Remember the difference
✅ Computed not working? Remove side effects
✅ Effect not triggering? Read signals inside the effect
✅ Async issues? Handle promises properly with try/catch
✅ Still stuck? Add debug effects to trace changes
The truth is, 99% of Signal update issues come down to one thing: treating Signals like regular variables. Once you internalize that Signals need immutable updates and proper method usage, these problems disappear.
💬 Your Turn!
Alright, I've shared my Signal debugging war stories — now I want to hear yours!
Here's my challenge for you:
- What's the weirdest Signal bug you've encountered? Drop it in the comments — I'll personally reply with a solution if you're still stuck! 👇
- Which of these 5 issues surprised you the most? Let me know — I'm genuinely curious which ones catch developers off guard.
- Got a different approach to handling Signals? Share it! The best part about our dev community is learning from each other.
🚀 Want to Level Up Your Angular Skills?
If this helped you squash that annoying Signal bug, here's how you can help me and get more content like this:
👏 Hit that clap button (you can clap up to 50 times — just saying 😉). It helps other devs discover these solutions!
📬 Follow me for more Angular deep dives — I publish practical tutorials every week, always with working code you can use immediately.
💌 Join my newsletter where I share exclusive tips, early access to articles, and answer reader questions directly. No spam, just solid dev content.
🔥 Bookmark this article — trust me, you'll need this reference when debugging Signals at 11 PM on a Friday (we've all been there).
🚀 Follow Me for More Angular & Frontend Goodness:
I regularly share hands-on tutorials, clean code tips, scalable frontend architecture, and real-world problem-solving guides.
- 💼 LinkedIn — Let’s connect professionally
- 🎥 Threads — Short-form frontend insights
- 🐦 X (Twitter) — Developer banter + code snippets
- 👥 BlueSky — Stay up to date on frontend trends
- 🌟 GitHub Projects — Explore code in action
- 🌐 Website — Everything in one place
- 📚 Medium Blog — Long-form content and deep-dives
- 💬 Dev Blog — Free Long-form content and deep-dives
- ✉️ Substack — Weekly frontend stories & curated resources
- 🧩 Portfolio — Projects, talks, and recognitions
- ✍️ Hashnode — Developer blog posts & tech discussions
🎉 If you found this article valuable:
- Leave a 👏 Clap
- Drop a 💬 Comment
- Hit 🔔 Follow for more weekly frontend insights
Let’s build cleaner, faster, and smarter web apps — together.
Stay tuned for more Angular tips, patterns, and performance tricks! 🧪🧠🚀
Top comments (1)
Nice and clear, but you should remove the
mutate
method since it has been removed .