A cart component lands in review. The quantity and the price are signals, which is fine. Then there is a third signal called total, and an effect whose whole job is to keep it in sync:
// The trap: looks reasonable, but total is not independent state
readonly quantity = signal(1);
readonly price = signal(25);
readonly total = signal(0);
constructor() {
effect(() => {
this.total.set(this.quantity() * this.price());
});
}
Nothing in this code looks careless. It runs. It even stays correct, right up until someone adds a discount, or a second place that writes to total, or a reason the effect fires in an order nobody expected. The problem is quieter than a bug. total isn't independent state. It's a fact derived from state. And derived facts do not need a signal plus a synchronization effect. They need computed():
// The fix
readonly quantity = signal(1);
readonly price = signal(25);
readonly total = computed(
() => this.quantity() * this.price()
);
That small correction is the whole post. Most Signals misuse is not an API problem. It is a modeling problem. The question that fixes it comes before any API:
What kind of state am I modeling?
Name the state first. The primitive follows naturally.
The decision table
There are five answers to that question. For common component-state problems, they give you a reliable starting point, and each one maps to one primitive:
| The state you are modeling | The primitive | The sentence in your head |
|---|---|---|
| Independent writable state | signal() |
I own this value. |
| A value derived purely from other signals | computed() |
This value follows other values. |
| Derived state that can also be locally overridden | linkedSignal() |
This value normally follows another value, but it can be overridden. |
| Async read state keyed by reactive state | resource() / httpResource() |
This value comes asynchronously from reactive state. |
| Synchronization with something outside the reactive graph | effect() |
Something outside my reactive graph needs to know that state changed. |

One rule sits on top of the table: if the thing you're about to write doesn't fit one of these rows cleanly, rethink the design before reaching for effect().
Notice where effect() sits in the list. Last. Angular's own guidance says effects should be the last API you reach for, and warns against using them to propagate state. Effects belong at the edges of the reactive model, where state leaves the graph. They are not the default way to connect two pieces of state inside it.
One example, five decisions
Watch the same small product state grow, one decision at a time.
You own the value: signal(). The shopper picks a quantity. Nothing derives it, nothing loads it. It is simply owned:
readonly quantity = signal(1);
readonly price = signal(25);
The value follows: computed(). The total is the cart example from the top. It is never written to directly, so it is never out of sync. In the template you read it the way you read any signal, by calling it: {{ total() }}, parentheses included.
It follows, but can be overridden: linkedSignal(). A product page suggests a variant, usually the first available one, but the shopper can pick a different one. That is derived state with a local override:
readonly variants = signal(['Black', 'White']);
// Follows variants()[0] until someone picks another one
readonly selectedVariant = linkedSignal(
() => this.variants()[0]
);
When the variants change, the selection follows again. When the shopper sets it, the override holds.
That reset is the tradeoff in the one-liner, and it is worth naming. Every time variants changes, the selection resets to variants()[0], even when the variant the shopper picked is still in the list. If the list refreshes in the background, a valid pick silently jumps back to the first option. When the previous selection should survive while it is still available, use the object form instead. It separates the source from the computation, and the computation receives a previous object holding the prior source value and the prior linked value:
// Keeps the current selection while it is still available
readonly selectedVariant = linkedSignal<string[], string>({
source: this.variants,
computation: (newVariants, previous) => {
const previousSelection = previous?.value;
return previousSelection && newVariants.includes(previousSelection)
? previousSelection
: newVariants[0];
},
});
One mechanical note from the docs: when the computation uses previous, write the generic type arguments out explicitly, as in <string[], string> above. The first is the source type, the second is the linked value's type.
And since Angular 22, linkedSignal() can customize its set operation, so writes can be routed back to the underlying source of truth instead of living as a quiet local exception.
It arrives asynchronously: resource() and httpResource(). Product details live on a server, and which product you need depends on reactive state, such as the selected product ID. That is a reactive read, and it is what resources are for:
readonly productId = signal('123');
// The URL is a function of reactive state
readonly product = httpResource<Product>(
() => `/api/products/${this.productId()}`
);
The template reads the resource through its state signals, and guards the value behind hasValue():
@if (product.hasValue()) {
<h2>{{ product.value().name }}</h2>
<p>Price: {{ product.value().price }}</p>
} @else if (product.error()) {
<p>Could not load the product.</p>
} @else if (product.isLoading()) {
<p>Loading...</p>
}
Three behaviors from the docs are worth knowing before you lean on this. The request starts eagerly, as soon as the resource is created. There is no subscription step like there is with a raw HttpClient call. When a signal the URL function reads changes, a request still in flight is cancelled and a new one is issued. And the value needs its guard: reading value() while the resource sits in its error state throws, which is why the template checks hasValue() first. Keep the boundary clean: resources are primarily for reads. A POST or PUT does not automatically become a resource just because it is asynchronous, and the httpResource docs steer mutations back to HttpClient.
Something outside must know: effect(). At the end, the cart total has to reach a place outside the reactive graph, for example a value persisted outside Angular. Now the effect earns its place, because its job is synchronization with the outside, not state propagation:
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
constructor() {
effect(() => {
if (!this.isBrowser) {
return;
}
localStorage.setItem('cart-total', String(this.total()));
});
}
One guard in that snippet is not optional in an app that renders on the server. An effect can run during server-side rendering, where localStorage does not exist, so the write sits behind an isPlatformBrowser check. isPlatformBrowser comes from @angular/common; PLATFORM_ID and inject come from @angular/core. Angular's render hooks are the other browser-only tool: afterNextRender() callbacks never run on the server at all, but they fire once, which suits one-time browser setup rather than a sync that has to repeat. And be explicit about what the persisted value is. It is a convenience copy, not the cart. The authoritative total is the computed one, backed by whatever server state a real shop keeps, and a stored number must never become a replacement for the actual cart model.
Same API as the trap at the top. Completely different job. The first effect existed to maintain derived state the graph could have modeled directly. This one exists because the graph ends here.
Where this lands today
The fundamentals are not experimental anymore. Angular 20 graduated the core primitives to stable, including signal(), effect(), linkedSignal(), signal-based inputs, and queries. resource() and httpResource() became stable in Angular 22, alongside Signal Forms.
Two defaults matter for how you write this code now. Zoneless became the default change-detection mode in Angular 21, and OnPush became the default component change-detection strategy in Angular 22.
Zoneless deserves one precise sentence, because it is often stated wrong: zoneless Angular does not mean everything must be a signal. It means Angular no longer relies on Zone.js to guess when application state may have changed. Applications need to update state through mechanisms Angular can observe, such as signals, template events, inputs, AsyncPipe, or explicit change-detection notifications.
Three traps that survive the table
Propagating state with effect(). Using an effect to push a value from one signal into another is usually a smell. Not because calling .set() inside an effect is forbidden, but because a derivable value modeled by hand is a synchronization bug waiting for a second writer. If it can be derived, use computed(). If it is derived but must stay writable, consider linkedSignal().
Mutating an object in place. Signals use referential equality by default, so changing a property on the stored object changes nothing Angular can see:
// Bad
user().name = 'Mo';
// Good
user.update(current => ({
...current,
name: 'Mo'
}));
Produce a new reference instead. The WritableSignal API gives you set(), update(), and asReadonly() for exactly this. There is no .mutate().
Side effects inside computed(), and effects created inside reactive contexts. A computed is lazy and memoized, so keep it pure, or the memoization lies to you. And do not create effects from inside reactive contexts: Angular actively prevents some of these patterns because they can repeatedly create reactive work and lead to unexpected behavior.
Signals did not retire RxJS
One boundary to keep straight before the cheat sheet. Signals do not replace RxJS completely. Signals are excellent for synchronous reactive state, which is most of what this post covered. RxJS remains useful for event streams, complex asynchronous composition, cancellation, multicasting, and similar stream-oriented problems. toSignal() and toObservable() are the bridge between the two models, and most real applications will use both sides of it.
Every example above runs in the companion playground: one Angular 22 project with declared fixtures and controls for the cart, the mutation demo, both linkedSignal forms, and the httpResource read, so the snippets are testable rather than decorative.
The cheat sheet
Before you write the next piece of Angular state, ask the one question and take the row that answers it:
- I own this value →
signal() - It follows other values →
computed() - It follows, but can be overridden →
linkedSignal() - It arrives asynchronously, keyed by reactive state →
resource()/httpResource() - Something outside the graph must know →
effect()
If none of the five rows fits cleanly, the design needs another minute, not another effect. And when a feature's state eventually outgrows a single component, the same five questions carry over unchanged. State does not get a new nature just because it moved.
Sources
The version-sensitive claims in this post trace to the official Angular documentation:
- Side effects for non-reactive APIs (the
effect()guide): https://angular.dev/guide/signals/effect - Dependent state with
linkedSignal: https://angular.dev/guide/signals/linked-signal - Reactive data fetching with
httpResource: https://angular.dev/guide/http/http-resource afterNextRenderAPI reference: https://angular.dev/api/core/afterNextRender



