effect() 是 Angular signal 裡很容易被誤用的一個 API。因為寫法直覺,只要讀取 signal,就能在值改變時執行一些事情,所以很常被拿來同步狀態、打 API,甚至再去修改其他 signal。
這些寫法雖然能動,但用多了會讓狀態之間的關係變得難追。這篇會從 effect() 的執行方式開始,整理相依追蹤、cleanup,以及哪些情況真的需要使用 effect()。
先看一個最簡單的例子:
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() 時,很容易把它理解成 signal 每次呼叫 set(),effect() 就會立刻執行,但實際上不是。
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() 的相依追蹤只會發生在同步執行期間。
effect(() => {
console.log(this.user());
setTimeout(() => {
console.log(this.theme());
});
});
這個 effect() 會追蹤同步讀取的 this.user(),但不會追蹤 setTimeout() 裡的 this.theme(),因為 Callback 執行時,原本的 Reactive Context 已經結束。subscribe() 的 Callback 和 await 之後的程式也是一樣。
![]()
例如下面這段程式:
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() 裡建立 Subscription,就要注意 effect() 可能會重新執行。
readonly form =
viewChild.required<NgForm>('form');
effect(() => {
this.form()
.form
.valueChanges
.subscribe(value => {
console.log(value);
});
});
每次 effect() 重跑,都會建立新的 Subscription,而前一次建立的 Subscription 不會自動取消。這時可以透過 onCleanup() 處理:
effect((onCleanup) => {
const form = this.form();
const sub =
form.form.valueChanges.subscribe(
value => {
console.log(value);
}
);
onCleanup(() => {
sub.unsubscribe();
});
});
onCleanup() 會在下一次 effect() 執行前呼叫,effect() 被銷毀時也會執行,因此可以用來清掉前一次建立的資源。

除了 Observable,也可以用來清除 Timer:
effect((onCleanup) => {
const id = setInterval(() => {
console.log(this.count());
}, 1000);
onCleanup(() => {
clearInterval(id);
});
});
Angular 會管理 effect() 本身的生命週期,但裡面建立的 Subscription、Timer 或其他外部資源,仍然要處理各自的清理。
有時候 effect() 裡需要讀取某個 signal,但不希望它成為觸發條件。
effect(() => {
console.log(
'user:',
this.user(),
'theme:',
this.theme()
);
});
這裡的 user 和 theme 都會成為相依。假設需求只是 User 改變時記錄 Log,並順便把目前的 Theme 一起印出來,就不希望 Theme 改變時也重新執行 effect()。
這時可以使用 untracked():
effect(() => {
const user = this.user();
const theme = untracked(this.theme);
console.log(
'user:',
user,
'theme:',
theme
);
});
這樣只有 this.user() 會成為相依,而 theme 雖然一樣可以讀取,但它的變化不會讓 effect() 再次執行。
如果希望直接寫出哪些 signal 會觸發副作用,也可以使用 ngxtension 提供的 explicitEffect()。
一般的 effect() 會依照 Callback 裡實際讀取的 signal 自動建立相依:
effect(() => {
console.log(
this.id(),
this.keyword()
);
});
這裡的 id 和 keyword 都會觸發 effect()。
改成 explicitEffect() 後,可以把要追蹤的 signal 直接列在第一個參數:
explicitEffect(
[this.id],
([id]) => {
console.log(id);
console.log(this.keyword());
}
);
這時只有 this.id 會觸發 Callback,即使裡面讀取了 this.keyword(),它也不會被加入相依。
如果裡面建立了 Subscription,也可以透過 cleanup 清理:
explicitEffect(
[this.id],
([id], cleanup) => {
const sub =
this.userService
.getUser(id)
.subscribe();
cleanup(() => {
sub.unsubscribe();
});
}
);
另外還可以透過 defer: true 跳過第一次執行:
explicitEffect(
[this.id],
([id]) => {
console.log(
'id changed:',
id
);
},
{
defer: true,
}
);
這樣只有後續 id 改變時才會執行 Callback。
explicitEffect() 的重點是讓相依來源更明確,適合用在希望清楚控制觸發條件的副作用;它改變的是相依追蹤方式,不會改變這段邏輯本身適不適合使用 effect()。
effect() 很常被拿來讀取一個 signal,再把結果寫進另一個 signal。
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():
readonly fullName =
computed(
() => `${this.firstName()} ${this.lastName()}`
);
重點不是看到 effect() 就一定要改成 computed(),而是當 effect() 裡又去 set() 另一個 signal 時,可以先確認這份狀態是不是真的需要另外保存。
另一個常見寫法,是用 signal 當查詢條件,在 effect() 裡發送 API 請求:
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 會更適合處理:
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() 轉回來:
readonly person =
toSignal(this.person$, {
initialValue: null,
});
如果只是「參數改變後重新取得資料」,前面介紹過的 resource()、httpResource()、rxResource() 也可以直接處理這類情境,並提供 Loading、Error、Value 等狀態。
重點不是 API 一定要改用哪一種,而是這類非同步資料取得,不一定需要靠 effect() 監聽參數、訂閱請求,再手動把結果寫回另一個 signal。
effect() 也常被拿來直接操作 DOM。
readonly fontSize =
signal(16);
effect(() => {
this.renderer.setStyle(
this.element.nativeElement,
'--main-font-size',
`${this.fontSize()}px`
);
});
如果只是把 signal 對應到畫面樣式,可以直接透過 Angular 的 Binding:
@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()。
前面的例子可以整理出一個比較簡單的判斷方式:如果事情仍然屬於 Angular 內部的狀態、非同步資料或畫面綁定,通常可以先使用對應的 API。
effect() 比較適合處理的是 signal 改變後,需要把結果交給 Angular 以外的 API。
例如把 Theme 寫進 Local Storage:
effect(() => {
localStorage.setItem(
'theme',
this.theme()
);
});
記錄 Log:
effect(() => {
console.log(
'current user:',
this.user()
);
});
或更新第三方圖表:
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() 處理。
effect() 由 Angular 排程執行,不在 set() 當下觸發。cleanup()。untracked() 讀取時不建立相依。effect() 的適用情境與 cleanup。await 之後不會追蹤相依。defer。