Skip to main content

RxState reactive discipline & effects

RxState comes with a discipline about how state is read, derived, and acted upon, beyond being a place to put state. Most of the friction people hit with it comes from ignoring that discipline: setting a value inside a stream that could have connected it, storing a derived number that a selection would have computed, running a side effect from a click handler that a declared effect would have owned. This page explains the reasoning underneath those rules, the "why" that the no-rxstate-* lint rules enforce and the rxEffects API embodies. It is conceptual: no step-by-step, no API surface. Once the mental model is in place, the individual guides and rules read as consequences of it.

The idea

Pure functions and side effects: the split everything rests on

Two terms carry the whole model.

A function is pure when its return value depends only on its arguments and its evaluation produces no observable change anywhere else. add(a, b) => a + b is pure: same inputs, same output, and nothing in the world is different afterward. A function is referentially transparent when you could replace a call to it with its result and the program would behave identically, which is what purity buys you.

A side effect is the opposite: a modification of state outside the local function scope, or any interaction with the world beyond returning a value, such as writing to the DOM, issuing a network request, logging, navigating, or persisting to storage. Side effects are not bad; an application that never touched the outside world would do nothing. The discipline is not "avoid side effects"; it is keep the pure part pure and make the impure part explicit, so you always know which is which and where each one lives.

Modern Angular already gives you the pure side by construction. A computed() signal is pure and auto-memoized: it derives a value from other signals, re-runs only when they change, and is glitch-free by design. RxState's job is to be honest about the other side: to give the impure work a declared, torn-down home instead of letting it scatter across constructors, click handlers, and stray subscriptions. The rest of this page is one question applied in three places, is this work pure derivation, or is it an effect?, and then routing each to the tool built for it.

Derive, don't store

The first place the split shows up is derivation. If a value can be computed from state you already hold, it is derived data, and derived data should be derived, not stored. Storing it duplicates a source of truth: now two keys can disagree, and every update has to remember to keep them in sync. Deriving it means there is exactly one place the value comes from, and it can never drift.

Concretely: keep only source-of-truth state in the container, and produce the rest on read. In-component, that read is a native computed() over a signal. In the reactive pipeline, when the derivation composes several observable sources or feeds a connect, it is a selection operator like select / selectSlice or stateful. The choice between them is not stylistic: use the signal read when the derivation is a plain in-component value, and stay in the RxJS pipeline when you are merging multiple streams. Either way the principle is the same: there is one source of truth, and derived values are functions of it, never copies of it.

The pipeline is disciplined by construction

When derivation does live in the reactive pipeline, RxState does not leave the guarantees to you. The accumulation pipeline that connect feeds enforces two of these invariants directly (distinct via distinctUntilChanged, shared-and-replayed via publishReplay). The third — the defined guarantee that filters undefined — is enforced by the stateful operator, which select applies on the read path. So the full set of invariants is only in effect when you read state through select:

  • distinct: consecutive duplicate states are dropped, so downstream work does not re-run for a value that did not change;
  • defined: undefined is filtered out, which keeps state lazy: consumers see a value only once there is one;
  • shared and replayed: one execution is reused across all subscribers, and a late subscriber immediately receives the current value.

This is the pipeline-side equivalent of what signals give you in-component. A signal is glitch-free and auto-memoized; connected RxState is distinct, lazy, and shared. In both worlds the framework guarantees that reads are consistent and cheap, which is why connecting a source is preferable to manually subscribing and setting: connected state is disciplined the moment it exists, and hand-rolled subscriptions are not.

Effects belong in rxEffects, not in state and not in the template

The impure side needs a home too, and that home is rxEffects, not the state container, and not the template.

The reasoning is about ownership of a lifecycle. A side effect triggered by a stream (a delete request fired on a click, a navigation on a route change, a write to localStorage on a preference change) has a subscription that must be created once and torn down exactly when its host is destroyed. Scatter those subscriptions across click handlers and lifecycle hooks and you own that bookkeeping by hand: subscribe, takeUntil(destroy$), ngOnDestroy. rxEffects owns it for you. You register a source and its effect in one place, and the subscription is bound to the injection context's DestroyRef: created at init, cleaned up on destroy, with no manual teardown. An error in one effect is forwarded to Angular's ErrorHandler and does not tear down its siblings.

There is a matching boundary with signals. When the trigger is an Observable or Promise, the effect belongs in rxEffects. When it reacts to a signal(), Angular's native effect() is the right tool. The dividing line is the shape of the trigger, not a preference; the two are complements, exactly as RxState and signals are on the state side (see E3).

One declared channel per event

The same "make it explicit, own its lifecycle" instinct applies to the events that drive state and effects. The undisciplined pattern is a scatter of raw Subjects across a component, each subscribed and cleaned up ad hoc, with value-extraction logic ( $event.target.value, and the like) repeated at every call site: once in the template, once again where the class triggers the same channel programmatically.

The disciplined pattern is a single declared action channel per event. One declaration turns each event into both a dispatchable setter and a readable stream, so template events and programmatic calls flow through the same channel; input transforms normalize the raw event into its meaningful value in one place, the way an Angular Input transform does; and cleanup is automatic. The payoff is not terseness for its own sake; it is that there is now exactly one place where every event, its transform, and any effect bound to it are declared, rather than a diffuse web of subjects you have to trace by hand.

Imperative and reactive can mix, but usually shouldn't

RxState deliberately supports both an imperative surface (get, set) and a reactive one (connect, and the class-era hold). Both are legitimate. What is a smell is mixing them in the wrong direction: reaching for the imperative methods inside a reactive context. Calling set inside a stream, for instance, is almost always a connect written the long way: you have a source Observable and you are manually pushing each emission into state instead of declaring the connection once. The declarative form is not merely tidier; it inherits the distinct/lazy/shared guarantees above, which the imperative version throws away.

This is the point the linter mechanizes. It is a judgment call (there are cases where an imperative set in a stream is the honest expression of intent) but the default should be declarative, and the rule flags the default violations so the exceptions are conscious ones.

Trade-offs / context

The discipline has a cost, and it is worth naming. Routing everything through declared channels, connected sources, and registered effects is more ceremony than a handful of Subjects and an ngOnDestroy; for a trivial component it can feel like overhead with no payoff. It earns its keep as a component's reactivity grows: the point at which you are tracking several subscriptions by hand, syncing a derived value you forgot to update, or chasing a leak from a subscription that outlived its view is exactly the point the discipline was designed for. On a two-field form it is overkill; on a data-heavy, stream-fed component it is what keeps the thing tractable.

It is also worth being clear that these are guidelines with escape hatches, not laws. The no-rxstate-subscriptions-outside-constructor rule, for example, ships an allowedMethods option precisely because a residual case sometimes needs to subscribe from ngOnInit. The rules encode the default, and a good default is one you override deliberately and rarely, not one you disable because it was inconvenient once.

Finally, a note on where this sits relative to modern Angular. Everything here assumes the same rendering model as the rest of the docs: signals drive change detection, computed() owns pure in-component derivation, effect() owns signal-triggered effects. RxState's discipline is not a competing paradigm; it is the same pure/impure split, applied to the observable-and-shared-state cases where signals alone are an awkward fit (see E1 for the change-detection ground beneath both). Read that way, the no-rxstate-* rules and the rxEffects API are not arbitrary house style; they are what "keep the pure part pure and make the impure part explicit" looks like once it is written down.

Referenced by

See also