Michael Lo

Command Palette

Search for a command to run...

Blog
PreviousNext

重新認識 Angular - Signals 響應式狀態管理

--

深入理解 Angular Signals 與 SignalStore

Signals 是 Angular 20 最重要的改變,運作方式和 React Hooks 不同——自動追蹤依賴、不需要 dependency array。

概念對照

ReactAngular說明
useStatesignal()響應式狀態
useMemocomputed()衍生計算值
useEffecteffect()副作用
useRefviewChild() / signal()DOM 參照或不觸發渲染的值
useCallback不需要Angular 沒有 re-render 問題
useContextinject()依賴注入
Zustand / ReduxSignalStore全域狀態管理

Signal 基礎

tsx
import { useState } from 'react';
 
function Counter() {
  const [count, setCount] = useState(0);
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>+</button>
      <button onClick={() => setCount(c => c - 1)}>-</button>
      <button onClick={() => setCount(0)}>Reset</button>
    </div>
  );
}

Signal 操作方法:

方法說明範例
signal(value)建立 signalsignal(0)
signal()讀取值count()
.set(value)設定新值count.set(5)
.update(fn)基於舊值更新count.update(c => c + 1)

Computed - 衍生狀態

tsx
import { useState, useMemo } from 'react';
 
function Cart() {
  const [items, setItems] = useState([
    { name: 'Apple', price: 100, qty: 2 },
    { name: 'Banana', price: 50, qty: 3 },
  ]);
 
  // 必須手動列出 dependency
  const total = useMemo(() => {
    return items.reduce((sum, item) => sum + item.price * item.qty, 0);
  }, [items]);
 
  return <p>Total: ${total}</p>;
}

computed()自動追蹤依賴,不需要手動維護 dependency array


Effect - 副作用

tsx
import { useState, useEffect } from 'react';
 
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
 
  useEffect(() => {
    // 必須列出 query 為 dependency
    const controller = new AbortController();
 
    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then(res => res.json())
      .then(setResults);
 
    // Cleanup function
    return () => controller.abort();
  }, [query]);
 
  return <ul>{results.map(r => <li key={r.id}>{r.name}</li>)}</ul>;
}

effect() 也會自動追蹤依賴,query 改變時會自動重新執行

Effect 進階用法

防抖搜尋(Debounce)

typescript
import { Component, signal, effect } from '@angular/core';
import { Subject, debounceTime } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 
@Component({...})
export class SearchComponent {
  query = signal('');
  private search$ = new Subject<string>();
 
  constructor() {
    // 監聽 query 變化,推送到 Subject
    effect(() => {
      this.search$.next(this.query());
    });
 
    // RxJS 處理防抖
    this.search$.pipe(
      debounceTime(300),
      takeUntilDestroyed()
    ).subscribe(q => this.fetchResults(q));
  }
}

localStorage 同步

typescript
@Component({...})
export class ThemeComponent {
  theme = signal<'light' | 'dark'>('light');
 
  constructor() {
    // 初始化:從 localStorage 讀取
    const saved = localStorage.getItem('theme') as 'light' | 'dark';
    if (saved) this.theme.set(saved);
 
    // 同步:theme 變化時寫入 localStorage
    effect(() => {
      localStorage.setItem('theme', this.theme());
    });
  }
}

Effect 注意事項

1. 避免無限迴圈

typescript
// ❌ 無限迴圈:effect 內修改自己追蹤的 signal
effect(() => {
  this.count.set(this.count() + 1); // 讀取 → 修改 → 觸發 → 讀取...
});
 
// ✅ 使用 untracked 避免追蹤
import { untracked } from '@angular/core';
 
effect(() => {
  const current = untracked(() => this.count());
  // 做一些不會觸發重新執行的操作
});

2. allowSignalWrites

預設情況下,effect 內不建議修改 signal,如果需要,要明確聲明:

typescript
effect(() => {
  if (this.items().length > 10) {
    this.hasMany.set(true); // 需要 allowSignalWrites
  }
}, { allowSignalWrites: true });

3. 執行時機

情境執行時機
初次建立Component 初始化後立即執行
Signal 變更下一個 change detection cycle
Component 銷毀自動清理,不需手動 unsubscribe

Effect 會在 component 銷毀時自動清理,但如果在 effect 內訂閱 Observable,記得用 onCleanup 取消訂閱


常見踩坑

1. 讀取 signal 要加 ()

typescript
// ❌ Wrong - this is the signal object
const value = this.count;
 
// ✅ Correct - this is the value
const value = this.count();

2. Template 中也要加 ()

html
<!-- ❌ Wrong -->
<p>{{ count }}</p>
 
<!-- ✅ Correct -->
<p>{{ count() }}</p>

3. 更新物件/陣列

typescript
// Array
items = signal<string[]>([]);
 
// ❌ 這樣不會觸發更新
this.items().push("new item");
 
// ✅ 使用 update
this.items.update((arr) => [...arr, "new item"]);

SignalStore - 全域狀態管理

SignalStore 來自 @ngrx/signals,是我目前最喜歡的 Angular 狀態管理方案,設計很像 Zustand,但整合了 Angular 的 DI 系統。

安裝

bash
npm install @ngrx/signals

基本用法對照

tsx
import { create } from 'zustand';
 
// 定義 store
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}
 
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));
 
// 使用 store
function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();
 
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

withComputed - 衍生狀態

typescript
import { signalStore, withState, withComputed } from "@ngrx/signals";
import { computed } from "@angular/core";
 
type CartState = {
  items: { name: string; price: number; qty: number }[];
};
 
export const CartStore = signalStore(
  withState<CartState>({
    items: [],
  }),
  withComputed((store) => ({
    total: computed(() =>
      store.items().reduce((sum, item) => sum + item.price * item.qty, 0),
    ),
    itemCount: computed(() => store.items().length),
  })),
);

withMethods - 非同步操作

typescript
import { signalStore, withState, withMethods, patchState } from "@ngrx/signals";
import { inject } from "@angular/core";
import { HttpClient } from "@angular/common/http";
import { firstValueFrom } from "rxjs";
 
type UserState = {
  users: User[];
  loading: boolean;
  error: string | null;
};
 
export const UserStore = signalStore(
  { providedIn: "root" }, // 全域 singleton
  withState<UserState>({
    users: [],
    loading: false,
    error: null,
  }),
  withMethods((store, http = inject(HttpClient)) => ({
    async loadUsers() {
      patchState(store, { loading: true, error: null });
      try {
        const users = await firstValueFrom(http.get<User[]>("/api/users"));
        patchState(store, { users, loading: false });
      } catch (err) {
        patchState(store, {
          error: "Failed to load users",
          loading: false,
        });
      }
    },
    clearError() {
      patchState(store, { error: null });
    },
  })),
);

SignalStore vs Zustand 對照

功能ZustandSignalStore
定義狀態create((set) => ({...}))withState({...})
衍生狀態手動 useMemowithComputed()
更新狀態set({ count: 1 })patchState(store, { count: 1 })
注入服務不支援inject(HttpClient)
Scope預設全域可選 component 或 root
DevTools需額外設定內建支援

SignalStore 可以直接 inject() Angular 服務(如 HttpClient),computed 自動追蹤依賴,和 Angular 生態系完美整合

何時使用 SignalStore?

情境建議方案
Component 內部狀態signal()
父子元件共享input() / output()
跨元件共享(同頁面)Service + signal()
全域狀態 + 複雜邏輯SignalStore
需要 DevTools 除錯SignalStore

系列文章

  1. 專案建立與 Component
  2. Signals 響應式狀態管理 ← 目前位置
  3. 路由、資料與表單
  4. 效能優化與部署