AngularSignalsNgRxState ManagementSignal Store

Signal-Store Mastery: Is NgRx Dead in the Age of Signals?

A
Angular GDE
Featured Guide 45 min read

From Services to Signals:
The State Management Journey

Angular's state management has evolved through three distinct eras. Understanding this evolution is key to knowing why Signals are revolutionary.

Era 1: Services with BehaviorSubject (2016-2020)

The original pattern. Simple, but required manual subscription management and led to memory leaks if you forgot to unsubscribe.

The Old Way: BehaviorSubject
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  private userSubject = new BehaviorSubject(null);
  user$ = this.userSubject.asObservable();

  setUser(user: User) {
    this.userSubject.next(user);
  }
}

// component.ts
export class ProfileComponent implements OnInit, OnDestroy {
  user: User | null = null;
  private destroy$ = new Subject();

  constructor(private userService: UserService) {}

  ngOnInit() {
    this.userService.user$
      .pipe(takeUntil(this.destroy$))
      .subscribe(user => this.user = user);
  }

  ngOnDestroy() {
    this.destroy$.next();
    this.destroy$.complete();
  }
}

Problems with BehaviorSubject

  • ✖ Manual subscription management
  • ✖ Easy to create memory leaks
  • ✖ Boilerplate for every subscription
  • ✖ No type safety on updates
  • ✖ Difficult to compose derived state

Era 2: NgRx Store (2017-2024)

Redux pattern brought predictability and time-travel debugging. But at what cost? Actions, Reducers, Effects, Selectors—a simple counter required 5 files.

NgRx: The Ceremony
// 1. actions.ts
export const loadUser = createAction('[User] Load User');
export const loadUserSuccess = createAction(
  '[User] Load User Success',
  props<{ user: User }>()
);

// 2. reducer.ts
export const userReducer = createReducer(
  initialState,
  on(loadUserSuccess, (state, { user }) => ({ ...state, user }))
);

// 3. effects.ts
@Injectable()
export class UserEffects {
  loadUser$ = createEffect(() =>
    this.actions$.pipe(
      ofType(loadUser),
      switchMap(() => this.userService.getUser()),
      map(user => loadUserSuccess({ user }))
    )
  );
}

// 4. selectors.ts
export const selectUser = createSelector(
  selectUserState,
  (state) => state.user
);

// 5. component.ts
export class ProfileComponent {
  user$ = this.store.select(selectUser);
  
  constructor(private store: Store) {}
  
  loadUser() {
    this.store.dispatch(loadUser());
  }
}

02. Why Signals? The Reactive Revolution

Signals solve the fundamental problems of both approaches:

🎯 No Subscriptions

Signals are synchronous. No subscribe(), no async pipe, no memory leaks. Just read the value.

⚡ Fine-Grained Updates

Only components reading a changed signal re-render. No Zone.js walking the entire tree.

🔒 Type Safe

TypeScript infers types automatically. No manual type guards or casting needed.

Signals: The Modern Way
// user.service.ts
@Injectable({ providedIn: 'root' })
export class UserService {
  // ✅ Signal instead of BehaviorSubject
  private _user = signal(null);
  user = this._user.asReadonly();

  setUser(user: User) {
    this._user.set(user);
  }
}

// component.ts
export class ProfileComponent {
  // ✅ No subscription needed!
  user = inject(UserService).user;

  // ✅ Computed values are automatic
  displayName = computed(() => {
    const user = this.user();
    return user ? \`${user.firstName} ${user.lastName}\` : 'Guest';
  });
}

// template

Welcome, {{ displayName() }}

Email: {{ user()?.email }}

What Changed?

  • ✔ No ngOnInit or ngOnDestroy
  • ✔ No takeUntil or async pipe
  • ✔ No manual subscription tracking
  • ✔ Computed values update automatically
  • ✔ Type-safe by default

Actions. Reducers. Selectors. Effects.
Enough.

NgRx implemented the Redux pattern perfectly. Too perfectly. For 90% of apps, the indirection was overkill.

The Signal Store (@ngrx/signals) is the answer. It combines the structure of NgRx with the simplicity of Signals, stripping away the ritualistic boilerplate.

04. The Signal Store: Functional Composition

The Signal Store is defined functionally. You compose features together. Need entities? Add withEntities. Need a computed value? Add withComputed.

Basic Signal Store
import { signalStore, withState, withMethods, withComputed } from '@ngrx/signals';
import { computed } from '@angular/core';

export const CounterStore = signalStore(
  { providedIn: 'root' },
  
  // 1. Define state
  withState({ count: 0 }),
  
  // 2. Add computed values
  withComputed(({ count }) => ({
    doubleCount: computed(() => count() * 2),
    isEven: computed(() => count() % 2 === 0)
  })),
  
  // 3. Add methods
  withMethods((store) => ({
    increment() {
      patchState(store, { count: store.count() + 1 });
    },
    decrement() {
      patchState(store, { count: store.count() - 1 });
    },
    reset() {
      patchState(store, { count: 0 });
    }
  }))
);

// Usage in component
@Component({
  selector: 'app-counter',
  standalone: true,
  template: \`
    

Count: {{ store.count() }}

Double: {{ store.doubleCount() }}

Is Even: {{ store.isEven() }}

\` }) export class CounterComponent { store = inject(CounterStore); }
Entity Management with withEntities
import { signalStore, withState, withEntities, withMethods } from '@ngrx/signals';
import { setAllEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';

interface Book {
  id: string;
  title: string;
  author: string;
}

export const BooksStore = signalStore(
  { providedIn: 'root' },
  
  withState({ loading: false, query: '' }),
  
  // ✅ Entities feature adds: entities, ids, entityMap
  withEntities(),
  
  withMethods((store, bookService = inject(BookService)) => ({
    async loadByQuery(query: string) {
      patchState(store, { loading: true, query });
      
      const books = await bookService.search(query);
      
      patchState(
        store, 
        setAllEntities(books),
        { loading: false }
      );
    },
    
    addBook(book: Book) {
      patchState(store, addEntity(book));
    },
    
    updateBook(id: string, changes: Partial) {
      patchState(store, updateEntity({ id, changes }));
    },
    
    removeBook(id: string) {
      patchState(store, removeEntity(id));
    }
  }))
);

// Usage
@Component({
  selector: 'app-books',
  standalone: true,
  template: \`
    
    
    @if (store.loading()) {
      

Loading...

} @for (book of store.entities(); track book.id) {

{{ book.title }}

by {{ book.author }}

} \` }) export class BooksComponent { store = inject(BooksStore); }

05. NgRx Classic vs Signal Store: Side by Side

Let's implement the same feature (a shopping cart) in both approaches:

Classic NgRx (5 files)

cart.actions.ts (40 lines)
addToCart, removeFromCart, clearCart...
cart.reducer.ts (60 lines)
on(addToCart, (state, { item }) => ...)
cart.selectors.ts (30 lines)
selectCartItems, selectTotal...
cart.effects.ts (50 lines)
saveToLocalStorage$, loadFromStorage$...
cart.component.ts (40 lines)
dispatch, select, async pipe...
Total: ~220 lines across 5 files

Signal Store (1 file)

export const CartStore = signalStore(
  { providedIn: 'root' },
  withEntities(),
  
  withComputed(({ entities }) => ({
    total: computed(() => 
      entities().reduce((sum, item) => 
        sum + item.price * item.quantity, 0
      )
    ),
    itemCount: computed(() => 
      entities().reduce((sum, item) => 
        sum + item.quantity, 0
      )
    )
  })),
  
  withMethods((store) => ({
    addItem(item: CartItem) {
      patchState(store, addEntity(item));
      localStorage.setItem('cart', 
        JSON.stringify(store.entities())
      );
    },
    removeItem(id: string) {
      patchState(store, removeEntity(id));
    },
    clear() {
      patchState(store, setAllEntities([]));
    }
  }))
);
Total: ~35 lines in 1 file

Classic NgRx

  • 4-5 files per feature
  • String-based Action Types
  • Effects allow hidden logic
  • High learning curve
  • Async pipe everywhere

Signal Store

  • Single file definition
  • Direct method calls
  • Extensible via custom features
  • Type-safe by default
  • No subscriptions needed

06. Real-World Examples

Example 1: Todo App with Filtering

import { signalStore, withState, withComputed, withMethods } from '@ngrx/signals';
import { withEntities, addEntity, updateEntity, removeEntity } from '@ngrx/signals/entities';

interface Todo {
  id: string;
  title: string;
  completed: boolean;
}

type Filter = 'all' | 'active' | 'completed';

export const TodoStore = signalStore(
  { providedIn: 'root' },
  
  withState({ filter: 'all' as Filter }),
  withEntities(),
  
  withComputed(({ entities, filter }) => ({
    // Filtered list based on current filter
    filteredTodos: computed(() => {
      const todos = entities();
      const currentFilter = filter();
      
      switch (currentFilter) {
        case 'active':
          return todos.filter(t => !t.completed);
        case 'completed':
          return todos.filter(t => t.completed);
        default:
          return todos;
      }
    }),
    
    // Statistics
    activeCount: computed(() => 
      entities().filter(t => !t.completed).length
    ),
    completedCount: computed(() => 
      entities().filter(t => t.completed).length
    )
  })),
  
  withMethods((store) => ({
    addTodo(title: string) {
      const todo: Todo = {
        id: crypto.randomUUID(),
        title,
        completed: false
      };
      patchState(store, addEntity(todo));
    },
    
    toggleTodo(id: string) {
      const todo = store.entityMap()[id];
      if (todo) {
        patchState(store, updateEntity({
          id,
          changes: { completed: !todo.completed }
        }));
      }
    },
    
    deleteTodo(id: string) {
      patchState(store, removeEntity(id));
    },
    
    setFilter(filter: Filter) {
      patchState(store, { filter });
    },
    
    clearCompleted() {
      const completedIds = store.entities()
        .filter(t => t.completed)
        .map(t => t.id);
      
      patchState(store, removeEntity(completedIds));
    }
  }))
);

// Component
@Component({
  selector: 'app-todos',
  standalone: true,
  template: \`
    
@for (todo of store.filteredTodos(); track todo.id) {
{{ todo.title }}
} @if (store.completedCount() > 0) { }
\` }) export class TodosComponent { store = inject(TodoStore); }

Example 2: User Authentication Store

interface User {
  id: string;
  email: string;
  name: string;
  role: 'admin' | 'user';
}

interface AuthState {
  user: User | null;
  token: string | null;
  loading: boolean;
  error: string | null;
}

export const AuthStore = signalStore(
  { providedIn: 'root' },
  
  withState({
    user: null,
    token: null,
    loading: false,
    error: null
  }),
  
  withComputed(({ user }) => ({
    isAuthenticated: computed(() => user() !== null),
    isAdmin: computed(() => user()?.role === 'admin'),
    userName: computed(() => user()?.name ?? 'Guest')
  })),
  
  withMethods((store, authService = inject(AuthService)) => ({
    async login(email: string, password: string) {
      patchState(store, { loading: true, error: null });
      
      try {
        const { user, token } = await authService.login(email, password);
        
        patchState(store, { 
          user, 
          token, 
          loading: false 
        });
        
        // Save to localStorage
        localStorage.setItem('auth_token', token);
      } catch (error) {
        patchState(store, { 
          error: error.message, 
          loading: false 
        });
      }
    },
    
    async register(email: string, password: string, name: string) {
      patchState(store, { loading: true, error: null });
      
      try {
        const { user, token } = await authService.register(
          email, 
          password, 
          name
        );
        
        patchState(store, { 
          user, 
          token, 
          loading: false 
        });
        
        localStorage.setItem('auth_token', token);
      } catch (error) {
        patchState(store, { 
          error: error.message, 
          loading: false 
        });
      }
    },
    
    logout() {
      patchState(store, { 
        user: null, 
        token: null, 
        error: null 
      });
      localStorage.removeItem('auth_token');
    },
    
    async loadFromToken() {
      const token = localStorage.getItem('auth_token');
      if (!token) return;
      
      patchState(store, { loading: true });
      
      try {
        const user = await authService.verifyToken(token);
        patchState(store, { user, token, loading: false });
      } catch {
        localStorage.removeItem('auth_token');
        patchState(store, { loading: false });
      }
    }
  }))
);

// Usage in component
@Component({
  selector: 'app-header',
  standalone: true,
  template: \`
    
@if (auth.isAuthenticated()) { Welcome, {{ auth.userName() }} @if (auth.isAdmin()) { Admin Panel } } @else { Login }
\` }) export class HeaderComponent { auth = inject(AuthStore); }

07. Advanced Patterns

Custom Features

You can create reusable features that can be composed into any store:

Custom withLoading Feature
// Create a reusable loading feature
export function withLoading() {
  return signalStoreFeature(
    withState({ loading: false }),
    withMethods((store) => ({
      setLoading(loading: boolean) {
        patchState(store, { loading });
      }
    }))
  );
}

// Create a reusable error handling feature
export function withErrorHandling() {
  return signalStoreFeature(
    withState({ error: null as string | null }),
    withMethods((store) => ({
      setError(error: string | null) {
        patchState(store, { error });
      },
      clearError() {
        patchState(store, { error: null });
      }
    }))
  );
}

// Compose them into a store
export const ProductStore = signalStore(
  { providedIn: 'root' },
  withEntities(),
  withLoading(),           // ✅ Reusable feature
  withErrorHandling(),     // ✅ Reusable feature
  
  withMethods((store, productService = inject(ProductService)) => ({
    async loadProducts() {
      store.setLoading(true);
      store.clearError();
      
      try {
        const products = await productService.getAll();
        patchState(store, setAllEntities(products));
      } catch (error) {
        store.setError(error.message);
      } finally {
        store.setLoading(false);
      }
    }
  }))
);

Optimistic Updates

export const PostStore = signalStore(
  { providedIn: 'root' },
  withEntities(),
  
  withMethods((store, postService = inject(PostService)) => ({
    async likePost(postId: string) {
      // 1. Optimistic update
      const post = store.entityMap()[postId];
      if (!post) return;
      
      patchState(store, updateEntity({
        id: postId,
        changes: { 
          likes: post.likes + 1,
          isLiked: true 
        }
      }));
      
      // 2. Make API call
      try {
        await postService.like(postId);
      } catch (error) {
        // 3. Rollback on error
        patchState(store, updateEntity({
          id: postId,
          changes: { 
            likes: post.likes,
            isLiked: false 
          }
        }));
        
        console.error('Failed to like post:', error);
      }
    }
  }))
);

08. When to Use What?

Component State

Use plain signals in the component

count = signal(0)

Feature State

Use Signal Store

CartStore, AuthStore

Global State

Use Classic NgRx (rare)

Time-travel debugging

Decision Tree

  • • State only used in one component? → Component signal
  • • State shared across multiple components? → Signal Store
  • • Need time-travel debugging or action logs? → Classic NgRx
  • • Building a new app in 2026? → Signal Store

09. The Senior Engineer's Verdict

Is Classic NgRx Dead?

For new projects: Yes. The Signal Store covers 95% of use cases with 20% of the code.

However, the global Store (Redux style) still has a place in massive enterprise apps where complete state serialization, time-travel debugging, and distinct action logs are non-negotiable requirements purely for audit trails. But for managing component or feature state? Use Signal Store.

The Future: Angular is doubling down on Signals. The framework is being rewritten to use Signals internally. Learning Signal Store now is an investment in the future of Angular.

🎯 Conclusion

The Angular Signal Store represents a paradigm shift in how we think about state management. By combining the best aspects of NgRx's structure with the simplicity and performance of Signals, it offers a compelling alternative that reduces boilerplate while maintaining type safety and predictability.

Key Takeaways

  • ✓ Signal Store reduces code by 75% compared to classic NgRx
  • ✓ No subscriptions needed - Signals handle reactivity automatically
  • ✓ Type-safe by default with full TypeScript inference
  • ✓ Composable features make stores infinitely scalable
  • ✓ Perfect for feature-level state management

When to Use

  • • New Angular projects (2024+)
  • • Feature-level state management
  • • Apps prioritizing developer experience
  • • Teams wanting less boilerplate
  • • Projects using Angular Signals

As Angular continues to evolve with Signals at its core, the Signal Store positions itself as the future-proof choice for state management. Whether you're building a new application or gradually migrating from NgRx, the patterns and principles covered in this guide will serve you well in the modern Angular ecosystem.