The idea is to catch Signal Anti-Patterns before they hit production. These are the rules I hold every signal-driven feature to. Each one comes down to the same idea: work with Angular's reactivity graph instead of around it. Get these right and signals do exactly what they promise, fine-grained updates, no wasted recomputation, predictable data flow.
This article has two parts:
- Best practices: Description, rule, examples
- Automated Signal Best Practices
Short Description
Never mutate a signal's value in place — confirmed from the article Calling .push()/.splice()/property-assignment on the object a signal returns doesn't notify subscribers. Always .set() or .update() with a new reference.
Don't use effect() to derive/sync state — use computed() If an effect's only job is to compute a value from other signals and store it via .set()/.update(), that's what computed() is for. Effects should be reserved for side effects (logging, DOM, storage), not state derivation — using them for derivation causes extra change-detection cycles and ordering bugs.
Don't declare signals/computed inside methods or functions signal()/computed() calls should live as class fields (or module-level), created once. Declaring them inside a method means a new signal instance is created on every call — reactivity breaks silently.
Set a custom equal comparator for object/array signals when needed By default signals use reference equality (===). If you routinely .set() a new object/array with the same logical content, you'll trigger downstream recomputation/re-renders needlessly, pass { equal: ... } where deep-equality matters.
Always register cleanup for effects with subscriptions/timers An effect() that starts a subscription or timer without an onCleanup callback leaks on re-run or destroy.
Use untracked() to break read/write feedback loops An effect that reads signal A and writes signal B, where B is also read elsewhere and can affect A, risks infinite/extra runs. Wrap reads that shouldn't be tracked in untracked().
Always provide initialValue to toSignal() Without it, the signal is undefined until the source observable emits, a common source of template/type errors when converting Observables to Signals.
Use resource() to fetch signal-dependent async data while automatically handling loading, error, and value states without manual wiring.
1. ⚠️ Treat Signal Values as Immutable
A signal only notifies its subscribers when you call .set() or .update(). Reaching into the current value and changing it directly, pushing into an array, splicing it, assigning a property on an object, never produces a new reference, so there's nothing for Angular to compare and nothing to notify. The rule I hold everyone to: every write to a signal produces a brand new value.
In the member list store, this looks like:
export class MemberListStore {
members = signal<GymMember[]>([]);
addMember(member: GymMember) {
this.members.update(members => [...members, member]);
}
removeMember(id: string) {
this.members.update(members => members.filter(m => m.id !== id));
}
}
Same rule for objects: this.selectedMember.update(m => ({ ...m, status: 'active' })), never a direct property assignment on this.selectedMember(). This is a non-negotiable rule for me, not a style preference.
2. ⚠️ Keep computed() Pure
computed() is meant to be a pure, synchronous derivation, nothing else. Angular can re-evaluate it more than once before its value is actually read, and it can re-run any time an upstream signal changes, whether or not anything is reading the result yet. I keep anything that isn't a straight derivation, HTTP calls, writes to other signals, logging, localStorage access, out of it entirely, because inside a computed() that work runs at a frequency I don't control.
This is how I keep pricing logic clean:
totalPrice = computed(() => {
const tier = this.selectedTier();
const members = this.memberCount();
return calculateElasticPrice(tier, members); // pure function, no I/O
});
// anything side-effecting, like analytics, lives in an effect and stays minimal
constructor() {
effect(() => {
const price = this.totalPrice();
untracked(() => this.analytics.track('price_calculated', { price }));
});
}
3. ⚠️ Derive State, Don't Sync It With effect()
Copying one signal's value into another signal inside an effect() builds a manual, imperative sync path for something computed() already handles for free. It's an extra change detection tick, it's harder to trace, and it can loop if you're not deliberate about what the effect reads versus writes. When I need a resettable or overridable derived value, which comes up constantly in stepper UIs, I reach for linkedSignal() instead of an effect.
In the member onboarding stepper:
// derive it directly
canContinue = computed(() => this.planSelected() && this.paymentDetailsValid());
// when I need an overridable derived value, linkedSignal is the right tool
currentStep = signal(0);
// resets to a computed default whenever the source changes, but stays writable
draftStepLabel = linkedSignal(() => this.stepDefinitions()[this.currentStep()].label);
4. ⚠️ Push Derived Values Through computed(), Not Template Functions
Calling a method in a template, {{ getTotal() }}, re-executes it on every change detection run regardless of whether its inputs changed, because Angular has no way to know the function is pure or what it depends on. Zoneless cuts down how often that happens overall, but it still re-runs on every relevant signal-driven pass instead of only when a real dependency changes. I always route derived template values through computed(), which only recalculates when a tracked dependency actually changes and caches the result otherwise.
In the revenue dashboard:
monthlyRecurringRevenue = computed(() =>
this.memberships()
.filter(m => m.status === 'active')
.reduce((sum, m) => sum + m.monthlyFee, 0)
);
<p>MRR: {{ monthlyRecurringRevenue() | currency:'GBP' }}</p>
5. ⚠️ Keep Signals Granular, Never One Signal Holding Everything
Before I ship a store, I run it through the same test:
Open your biggest signal and ask: does everything in here change together? If not, split it.
A single large signal holding unrelated state means any update to any field invalidates every computed() and effect() reading the whole object, even the ones that only care about a field that didn't change. Independently changing signals mean each consumer only re-runs for the slice it actually depends on.
This is how I structure the dashboard state:
memberCount = signal(42);
selectedTier = signal<'elastic-lite' | 'elastic-pro' | 'elastic-max'>('elastic-pro');
themeMode = signal<'dark' | 'light'>('dark');
searchQuery = signal('');
isSidebarOpen = signal(true);
// pricing only recomputes when the 2 signals it actually needs change
totalPrice = computed(() => calculateElasticPrice(this.selectedTier(), this.memberCount()));
My rule of thumb: I only group fields into one signal if they're always read and written together, like an { x, y } coordinate pair. Otherwise, I split them.
6. ⚠️ Guard Derived Objects Against Reference Churn
Signals and computed() only skip notifying downstream consumers when the new value is equal to the old one by reference. If a computed() returns a new object or array literal on every run, even one structurally identical to the last result, every consumer treats it as changed: OnPush components re-render, effects re-fire, @Input() bindings churn. I check every computed() that returns an object or array for exactly this, since it's the one place fine-grained reactivity can quietly turn into the opposite of what it promises.
In the member profile card:
// option A: supply a custom equality function
memberDisplay = computed(
() => ({ name: this.member().name, status: this.member().status }),
{ equal: (a, b) => a.name === b.name && a.status === b.status }
);
// option B: split into primitive signals instead, usually simpler
memberName = computed(() => this.member().name);
memberStatus = computed(() => this.member().status);
Same principle applies to arrays: I either return the same reference when nothing's changed, or add an equal comparator.
7. ⚠️ Reserve effect() for True Side Effects
I keep effect() scoped to work that genuinely lives outside Angular's reactivity graph: writing to localStorage, calling a non-Angular library, manual DOM work, logging, analytics. Everything else already has a declarative mechanism, template bindings, computed(), @if/@for, that's optimized, tracked, and cleaned up automatically. Using effect() for anything those can do means rebuilding what Angular's binding system already does, on an untracked path that's harder to reason about and can fire more often than expected, including once immediately on creation.
In the onboarding stepper's active-step styling, I let the template do the work:
@for (step of steps(); track step.id; let i = $index) {
<div class="step" [class.step--active]="i === currentStep()">
{{ step.label }}
</div>
}
Where effect() is exactly the right tool, onboarding progress auto-saving to disk, a genuine external side effect:
constructor() {
effect(() => {
const draft = this.onboardingForm.getRawValue();
untracked(() => localStorage.setItem('onboarding-draft', JSON.stringify(draft)));
});
}
8. ⚠️ Use resource() for Async Data Tied to a Signal
Practices 2 and 3 rule out doing async work inside computed() and rule out using effect() to hand-roll state syncing. That leaves an obvious question: what's the right way to fetch data that depends on a signal? For that I reach for resource(), which ties an async load directly to a signal input and gives me loading, error, and value state for free, without wiring up a data signal, a loading signal, and an error signal by hand and keeping all three in sync myself.
resource() reloads automatically whenever the signals read inside params change, and it cancels an in-flight request if a new one supersedes it, through the abortSignal it passes to the loader. Loading a location's member roster looks like this:
selectedLocationId = signal<string>('loc-001');
memberRoster = resource({
params: () => ({ locationId: this.selectedLocationId() }),
loader: ({ params, abortSignal }) =>
this.membersApi.fetchRoster(params.locationId, { signal: abortSignal })
});
@if (memberRoster.isLoading()) {
<app-spinner />
} @else if (memberRoster.error(); as err) {
<app-error-banner [error]="err" />
} @else {
<app-member-list [members]="memberRoster.value() ?? []" />
}
One detail I keep in mind: only signals read inside params are tracked. A signal read inside loader itself won't trigger a reload, since Angular can't track dependencies across an await. Anything the loader needs has to come in through params.
Quick Reference
| # | Practice | Why it matters | How I apply it |
|---|---|---|---|
| 1 | Treat signal values as immutable | Direct mutation never triggers notification |
.update() / .set() with new references |
| 2 | Keep computed() pure |
Side effects inside it run at an unpredictable frequency | Pure derivations only, side effects go in effect()
|
| 3 | Derive state, don't sync it |
effect()-driven sync adds ticks and can loop |
Use computed(), use linkedSignal() for overridable derived state |
| 4 | Push derived values through computed()
|
Template function calls re-run on every CD pass | Replace template functions with computed()
|
| 5 | Keep signals granular | One large signal invalidates everything on any change | Split into independently changing signals |
| 6 | Guard against reference churn | Fresh object/array literals always read as "changed" | Custom equal comparator, or split into primitives |
| 7 | Reserve effect() for true side effects |
Declarative bindings already cover the rest | Template bindings/computed() first, effect() for real externalities |
| 8 | Use resource() for signal-driven async data |
Manual data/loading/error signals are easy to get out of sync |
resource() with params/loader, driven off a signal |
2. Automated Signal Best Practices
How it works under the hood:
Uses a custom AWK script to track parenthesis depth, ensuring we accurately scan entire effect() or toSignal() blocks without being tricked by multi-line formatting.
What it catches:
🚫 Hard Fails: In-place mutations (.push(), .splice())
⚠️ Warnings: effect() deriving state (use computed())
⚠️ Warnings: Async fetching in effect() (use resource())
📊 Stats: effect() vs onCleanup() ratios (leak detection)
Here is how you can format all 8 rules together in that exact minimalist style for your slide:
The "Under the Hood" Concept
# 1. In-place mutations
grep -rn "\(\)\.push\|\(\)\.splice" src/
# 2. effect() deriving state
grep -A5 "effect(" src/ | grep "\.set\("
# 3. Signals inside functions
grep -rn "const .* = signal(" src/
# 4. Array signals (needs review)
grep -rn "signal<.*\[\]>" src/
# 5-6. Cleanup/untracked stats
grep -c "effect(" src/ | awk '{s+=$1}...'
# 7. Missing initial values
grep -A3 "toSignal(" src/ | grep -v "initialValue"
# 8. Async in effect()`
grep -A5 "effect(" src/ | grep "\.subscribe\|http\."
🔗 Grab the full script here:
https://gist.github.com/leolanese/8443691d4973634ca2188ab42c9ce42a
Top comments (0)