正在載入索引…

Day 17:effect() 不是拿來同步 Signal 的,Angular 的 Side Effect 該怎麼寫?

6.5k 字,約 6 分鐘

2026 iThome 鐵人賽:Angular 22 Signal 進化論。 原文發表於 iThome,也可以在下方留言。

文章目錄
  1. effect() 的基本行為
  2. effect() 不會在每次 set() 時立刻執行
  3. effect() 只追蹤同步讀取的 signal
  4. effect() 裡建立訂閱要記得 cleanup
  5. 不想追蹤某個 signal,可以使用 untracked()
  6. explicitEffect 明確指定相依
  7. 不要用 effect() 搬動狀態
  8. 打 API 也不一定需要 effect()
  9. 改畫面樣式也不一定需要 effect()
  10. 什麼時候才需要 effect()?
  11. 本日結語
    1. 資料來源

effect() 是 Angular signal 裡很容易被誤用的一個 API。因為寫法直覺,只要讀取 signal,就能在值改變時執行一些事情,所以很常被拿來同步狀態、打 API,甚至再去修改其他 signal。

這些寫法雖然能動,但用多了會讓狀態之間的關係變得難追。這篇會從 effect() 的執行方式開始,整理相依追蹤、cleanup,以及哪些情況真的需要使用 effect()。

effect() 的基本行為

先看一個最簡單的例子:

ts
import {
  Component,
  effect,
  signal,
} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <button (click)="increase()">
      {{ count() }}
    </button>
  `,
})
export class AppComponent {
  readonly count = signal(0);

  constructor() {
    effect(() => {
      console.log('count:', this.count());
    });
  }

  increase() {
    this.count.update(
      value => value + 1
    );
  }
}

effect() 執行時,會記錄同步讀取過的 signal。上面的程式讀取了 this.count(),所以 count 會成為相依,之後只要它改變,Angular 就會再次安排 effect() 執行。

和其他 signal API 一樣,effect() 會根據執行時讀取到的 signal 建立相依。不過它的用途不是產生新的狀態,而是在相依改變後執行一段程式。

effect() 不會在每次 set() 時立刻執行

第一次使用 effect() 時,很容易把它理解成 signal 每次呼叫 set(),effect() 就會立刻執行,但實際上不是。

ts
import {
  Component,
  effect,
  signal,
} from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <p>num:{{ num() }}</p>
  `,
})
export class AppComponent {
  readonly num = signal(2);

  constructor() {
    effect(() => {
      console.log(this.num());
    });

    this.num.set(4);

    setTimeout(() => {
      this.num.set(8);
    }, 1000);

    this.num.set(12);
  }
}

執行後會先印出 12,一秒後再印出 8,中間的 2 和 4 不會依序出現。

原因是 effect() 不會在每次 set() 當下同步執行,而是由 Angular 排程。set(4) 和 set(12) 都發生在 effect() 再次執行以前,所以等它真正執行時,讀到的已經是 12。

所以 effect() 看到的是執行當下的最新狀態,不是每一次發生過的變化。如果每個值都不能漏掉,例如操作事件、WebSocket 訊息,或需要保留完整變化順序的資料流,就不適合只靠 effect(),這類需求通常更適合使用 RxJS、Output 或其他事件機制。

effect() 只追蹤同步讀取的 signal

effect() 的相依追蹤只會發生在同步執行期間。

ts
effect(() => {
  console.log(this.user());

  setTimeout(() => {
    console.log(this.theme());
  });
});

這個 effect() 會追蹤同步讀取的 this.user(),但不會追蹤 setTimeout() 裡的 this.theme(),因為 Callback 執行時,原本的 Reactive Context 已經結束。subscribe() 的 Callback 和 await 之後的程式也是一樣。

例如下面這段程式:

ts
effect(() => {
  const id = this.userId();

  this.userService
    .getUser(id)
    .subscribe(() => {
      console.log(this.theme());
    });
});

this.userId() 是在 effect() 本體同步執行時讀取,所以會成為相依;this.theme() 則是在 subscribe() 的 Callback 裡讀取,因此 theme 改變時不會讓 effect() 重新執行。

這和前面介紹過的 Reactive Context 是同一件事:Angular 只會記錄同步執行期間讀取到的 signal。

effect() 裡建立訂閱要記得 cleanup

如果在 effect() 裡建立 Subscription,就要注意 effect() 可能會重新執行。

ts
readonly form =
  viewChild.required<NgForm>('form');

effect(() => {
  this.form()
    .form
    .valueChanges
    .subscribe(value => {
      console.log(value);
    });
});

每次 effect() 重跑,都會建立新的 Subscription,而前一次建立的 Subscription 不會自動取消。這時可以透過 onCleanup() 處理:

ts
effect((onCleanup) => {
  const form = this.form();

  const sub =
    form.form.valueChanges.subscribe(
      value => {
        console.log(value);
      }
    );

  onCleanup(() => {
    sub.unsubscribe();
  });
});

onCleanup() 會在下一次 effect() 執行前呼叫,effect() 被銷毀時也會執行,因此可以用來清掉前一次建立的資源。

除了 Observable,也可以用來清除 Timer:

ts
effect((onCleanup) => {
  const id = setInterval(() => {
    console.log(this.count());
  }, 1000);

  onCleanup(() => {
    clearInterval(id);
  });
});

Angular 會管理 effect() 本身的生命週期,但裡面建立的 Subscription、Timer 或其他外部資源,仍然要處理各自的清理。

不想追蹤某個 signal,可以使用 untracked()

有時候 effect() 裡需要讀取某個 signal,但不希望它成為觸發條件。

ts
effect(() => {
  console.log(
    'user:',
    this.user(),
    'theme:',
    this.theme()
  );
});

這裡的 user 和 theme 都會成為相依。假設需求只是 User 改變時記錄 Log,並順便把目前的 Theme 一起印出來,就不希望 Theme 改變時也重新執行 effect()。

這時可以使用 untracked():

ts
effect(() => {
  const user = this.user();
  const theme = untracked(this.theme);

  console.log(
    'user:',
    user,
    'theme:',
    theme
  );
});

這樣只有 this.user() 會成為相依,而 theme 雖然一樣可以讀取,但它的變化不會讓 effect() 再次執行。

explicitEffect 明確指定相依

如果希望直接寫出哪些 signal 會觸發副作用,也可以使用 ngxtension 提供的 explicitEffect()。

一般的 effect() 會依照 Callback 裡實際讀取的 signal 自動建立相依:

ts
effect(() => {
  console.log(
    this.id(),
    this.keyword()
  );
});

這裡的 id 和 keyword 都會觸發 effect()。

改成 explicitEffect() 後,可以把要追蹤的 signal 直接列在第一個參數:

ts
explicitEffect(
  [this.id],
  ([id]) => {
    console.log(id);
    console.log(this.keyword());
  }
);

這時只有 this.id 會觸發 Callback,即使裡面讀取了 this.keyword(),它也不會被加入相依。

如果裡面建立了 Subscription,也可以透過 cleanup 清理:

ts
explicitEffect(
  [this.id],
  ([id], cleanup) => {
    const sub =
      this.userService
        .getUser(id)
        .subscribe();

    cleanup(() => {
      sub.unsubscribe();
    });
  }
);

另外還可以透過 defer: true 跳過第一次執行:

ts
explicitEffect(
  [this.id],
  ([id]) => {
    console.log(
      'id changed:',
      id
    );
  },
  {
    defer: true,
  }
);

這樣只有後續 id 改變時才會執行 Callback。

explicitEffect() 的重點是讓相依來源更明確,適合用在希望清楚控制觸發條件的副作用;它改變的是相依追蹤方式,不會改變這段邏輯本身適不適合使用 effect()。

不要用 effect() 搬動狀態

effect() 很常被拿來讀取一個 signal,再把結果寫進另一個 signal。

ts
readonly firstName =
  signal('Antonio');

readonly lastName =
  signal('Ling');

readonly fullName =
  signal('');

effect(() => {
  this.fullName.set(
    `${this.firstName()} ${this.lastName()}`
  );
});

這段程式可以正常運作,但 fullName 完全由 firstName 和 lastName 決定,沒有必要另外維護一份 Writable Signal。

這類可以直接從其他狀態推算出來的值,可以直接使用 computed():

ts
readonly fullName =
  computed(
    () => `${this.firstName()} ${this.lastName()}`
  );

重點不是看到 effect() 就一定要改成 computed(),而是當 effect() 裡又去 set() 另一個 signal 時,可以先確認這份狀態是不是真的需要另外保存。

打 API 也不一定需要 effect()

另一個常見寫法,是用 signal 當查詢條件,在 effect() 裡發送 API 請求:

ts
readonly searchId =
  signal('');

readonly person =
  signal<Person | null>(null);

effect(() => {
  const id = this.searchId();

  this.personService
    .getPerson(id)
    .subscribe(person => {
      this.person.set(person);
    });
});

一開始看起來不複雜,但搜尋功能通常還會加入 debounce、取消上一筆請求、避免重複查詢、錯誤處理,以及 Component Destroy 時取消訂閱等需求。

如果需求包含這些非同步流程,RxJS 會更適合處理:

ts
readonly searchId =
  signal('');

private readonly searchId$ =
  toObservable(this.searchId);

readonly person$ =
  this.searchId$.pipe(
    debounceTime(300),
    distinctUntilChanged(),
    switchMap(id =>
      this.personService.getPerson(id)
    ),
    takeUntilDestroyed()
  );

如果畫面最後還是希望使用 signal,可以再透過 toSignal() 轉回來:

ts
readonly person =
  toSignal(this.person$, {
    initialValue: null,
  });

如果只是「參數改變後重新取得資料」,前面介紹過的 resource()、httpResource()、rxResource() 也可以直接處理這類情境,並提供 Loading、Error、Value 等狀態。

重點不是 API 一定要改用哪一種,而是這類非同步資料取得,不一定需要靠 effect() 監聽參數、訂閱請求,再手動把結果寫回另一個 signal。

改畫面樣式也不一定需要 effect()

effect() 也常被拿來直接操作 DOM。

ts
readonly fontSize =
  signal(16);

effect(() => {
  this.renderer.setStyle(
    this.element.nativeElement,
    '--main-font-size',
    `${this.fontSize()}px`
  );
});

如果只是把 signal 對應到畫面樣式,可以直接透過 Angular 的 Binding:

ts
@Component({
  selector: 'app-root',
  host: {
    '[style.--main-font-size]':
      'fontSizePx()',
  },
})
export class AppComponent {
  readonly fontSize =
    signal(16);

  readonly fontSizePx =
    computed(
      () => `${this.fontSize()}px`
    );
}

這樣狀態和畫面的關係直接寫在 Host Binding 上,不需要再透過 effect() 手動修改 DOM。

如果是第三方元件、Canvas,或 Angular Binding 不方便處理的外部 API,再考慮使用 effect()。

什麼時候才需要 effect()?

前面的例子可以整理出一個比較簡單的判斷方式:如果事情仍然屬於 Angular 內部的狀態、非同步資料或畫面綁定,通常可以先使用對應的 API。

effect() 比較適合處理的是 signal 改變後,需要把結果交給 Angular 以外的 API。

例如把 Theme 寫進 Local Storage:

ts
effect(() => {
  localStorage.setItem(
    'theme',
    this.theme()
  );
});

記錄 Log:

ts
effect(() => {
  console.log(
    'current user:',
    this.user()
  );
});

或更新第三方圖表:

ts
effect(() => {
  this.chart.update(
    this.chartData()
  );
});

這些操作的目的都不是建立另一份 Angular 狀態,而是讓外部系統跟著目前的 signal 狀態更新。

本日結語

effect() 本身沒有問題,真正要注意的是裡面放了什麼。

如果是在維護另一份狀態、處理非同步資料或更新 Angular 可以直接綁定的畫面,可以先看看有沒有更合適的 API;如果是在 signal 改變後通知 Local Storage、Logging、Canvas 或第三方 Library,就比較符合 Side Effect 的用途。

另外,effect() 不是每次 set() 都同步觸發的事件監聽器。它會由 Angular 排程執行,只追蹤同步執行期間讀取到的 signal,而裡面建立的 Subscription、Timer 或其他外部資源,也要記得透過 onCleanup() 處理。

資料來源

留言

留言使用 GitHub 帳號登入(Giscus),留言會存在 GitHub Discussions。