DEV Community

Cover image for Angular Signals Best Practices I Apply in Production
Leo Lanese
Leo Lanese

Posted on

Angular Signals Best Practices I Apply in 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.


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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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 }));
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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)
);
Enter fullscreen mode Exit fullscreen mode
<p>MRR: {{ monthlyRecurringRevenue() | currency:'GBP' }}</p>
Enter fullscreen mode Exit fullscreen mode

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()));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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)));
  });
}
Enter fullscreen mode Exit fullscreen mode

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 })
});
Enter fullscreen mode Exit fullscreen mode
@if (memberRoster.isLoading()) {
  <app-spinner />
} @else if (memberRoster.error(); as err) {
  <app-error-banner [error]="err" />
} @else {
  <app-member-list [members]="memberRoster.value() ?? []" />
}
Enter fullscreen mode Exit fullscreen mode

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

💯 Thanks!

🔘 linkedin: @LeoLanese
🔘 Twitter: @LeoLanese
🔘 DEV.to: Blog
🔘 Questions / Suggestion / Recommendation: engineer@leolanese.com

Top comments (0)