Skip to main content

Command Palette

Search for a command to run...

Your Components Know Too Much About NgRx

Do you still need an NgRx facade in the SignalStore era?

Updated
•View as Markdown
Your Components Know Too Much About NgRx
M
Hi there! I'm Mo' Claudius, a full-stack engineer with over 9 years of experience. I build with Angular, React, Ruby on Rails, Node.js, and NestJS, and most of my thinking goes to architecture and performance: event-driven and domain-driven design, optimization, and how the pieces fit and run. I publish here twice a week. The throughline is always the same: where should the boundary sit, what does an abstraction cost, and what breaks when you get it wrong. Every post ships with code you can run. When I'm not coding, I practice savate (French kickboxing) and dance kizomba. Both keep me sharp in ways that feed back into the work. I share knowledge the way you'd share dishes at a potluck: bring something real, take something useful. Find me on X at @mo_claudius.

A lot of NgRx pain is not really NgRx pain. It is coupling pain.

The component that knows too much

Picture an orders dashboard: a list with a status filter, a detail panel, a button that marks an order shipped. State is normalized with the entity adapter, effects talk to an OrdersApiService, everything lives in a feature folder the way the docs suggest. Then you open OrderListComponent and find this.

import { Component, inject, OnInit } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable, map } from 'rxjs';
import { ordersActions } from '../data-access/orders.actions';
import {
  selectFilteredOrders,
  selectOrdersLoading,
  selectOrderStatuses,
} from '../data-access/orders.selectors';
import { Order, OrderStatus } from '../data-access/order.model';

@Component({
  selector: 'app-order-list',
  templateUrl: './order-list.component.html',
})
export class OrderListComponent implements OnInit {
  private readonly store = inject(Store);

  readonly orders$: Observable<readonly Order[]> =
    this.store.select(selectFilteredOrders);
  readonly loading$: Observable<boolean> =
    this.store.select(selectOrdersLoading);
  readonly statuses$: Observable<readonly OrderStatus[]> =
    this.store.select(selectOrderStatuses);

  readonly overdueCount$: Observable<number> = this.orders$.pipe(
    map((orders) => orders.filter((order) => order.dueDate < Date.now()).length)
  );

  ngOnInit(): void {
    this.store.dispatch(ordersActions.loadOrders());
  }

  setStatusFilter(status: OrderStatus | 'all'): void {
    this.store.dispatch(ordersActions.setStatusFilter({ status }));
  }
}

Count what this component knows. It knows how state is queried: the selector names, the fact that filtering runs through something called selectFilteredOrders. To be fair, it does not know the feature state's shape. Rename the slice or split it, and this component is untouched while the selector contracts hold. Selectors already isolate read representation. But the component still names the selectors, still dispatches actions by hand, and still imports the store. It knows how changes are requested: the action names, the dispatch protocol, the loading handshake. And it knows the machinery underneath: NgRx is imported at the top of the file and referenced in every method. Two more kinds of knowledge, and neither one has anything to do with rendering a table. The costs follow from that knowledge.

Selectors isolate read representation. Facades can additionally isolate commands and the state-management technology. That is the whole argument in two sentences.

So here is the question this post tries to answer. What if the component only knew about orders, and nothing about NgRx?

Before and after: three components with tangled arrows into the store, versus one arrow each into a facade

Thirteen arrows versus four. The visual punchline of the whole article.

The war story

I've seen versions of this problem in real Angular features. The pattern usually looks something like this.

A feature stores order status as a plain string. That works fine until the day the status needs to carry more information, say a timestamp alongside the value. The change itself is small. But the string representation has leaked everywhere. Our selectors exposed the stored status directly, so that representation had become part of the UI's contract. Components select the raw status and compare it against string literals. Two of them derive "overdue" with slightly different logic. Tests override the same selectors to build their fixtures. None of those components are badly written. The problem is the boundary. Too much of the application knows how the store represents its data.

So a small change to a type becomes a scavenger hunt through the UI layer. Every file that assumed the old shape gets touched, including tests that had no real reason to know how the feature stored its data.

That pattern changed the question I ask when looking at NgRx code. Not "is this a good selector?" or "is this a good action?" but: how much does this component need to know about NgRx at all?

Four problems, four answers

The war story names the disease. Here are its four symptoms, each paired with what a boundary does about it.

Selector sprawl becomes a stable feature API. Two components need "overdue orders." Each writes its own derivation, six months apart, by two different authors, and they drift.

// order-list.component.ts
readonly overdueCount$ = this.orders$.pipe(
  map((orders) => orders.filter((o) => o.dueDate < Date.now()).length)
);

// order-detail.component.ts, six months later, different author
readonly isOverdue$ = this.order$.pipe(
  map((order) => order.dueDate < Date.now() && order.status !== 'shipped')
);

Neither is wrong in isolation. Together they disagree about what "overdue" means, and that disagreement surfaces at quarter-end. A facade does not solve this duplication by itself. Centralizing the derivation does. What the facade adds is a stable place for the component to consume that derivation, facade.overdueCount$, without knowing which selector implements it. The definition of overdue lives in exactly one place.

One honest caveat: Date.now() does not re-emit when a deadline passes. Centralizing the definition fixes the disagreement about what "overdue" means, but refreshing time-dependent state needs an explicit clock or refresh strategy. The facade centralizes the definition. It does not invent freshness.

Action leakage becomes controlled commands. Components dispatch actions they should never know about. The usual suspect is the loaded action: only the effect should dispatch ordersLoaded, but nothing stops a component from importing it, and importable internal actions get misused under deadline pressure. The boundary answers with plain methods: load(), updateStatus(id, status). Internal actions are not part of the feature's public API, and with module-boundary enforcement in place, consumers cannot reach around the facade to import them. The lint rule enforces what code review keeps missing. The misuse is rarely malicious: someone is in a hurry, the action is exported, the types line up, and the bill arrives months later.

Store-aware tests become simple service mocks. provideMockStore exists and it works. Tests that override stable selectors can survive state-shape changes. But the test still imports the store and still names its selectors, so it carries a dependency on selector names, action contracts, and NgRx testing utilities. A facade removes all three. The boundary answers with a plain object:

providers: [
  {
    provide: OrdersFacade,
    useValue: {
      filteredOrders$: of(mockOrders),
      loading$: of(false),
      load: () => undefined,
    },
  },
],

The test knows about orders. Nothing else.

Refactor blast radius becomes an implementation boundary. Split the orders slice into orders plus orderFilters. If the selector contracts survive the split, components and tests are untouched. When they do not, every consumer of the old shape gets edited. The facade narrows that further: it removes the dependency on selector names, action contracts, and the state-management technology itself, so the component diff is zero lines even when the store's surface changes.

The facade is a boundary, not a library

The Gang of Four defined a facade as a unified, simplified interface to a set of interfaces in a subsystem. In Angular state management, the subsystem is actions, reducers, selectors, and effects. The unified interface is one injectable service per feature.

What it is not matters more: not a state management library, not a replacement for the store, not a place for business logic to hide. It is a boundary between the feature's public API and the machinery that implements it.

This is a community architectural pattern rather than a required NgRx layer. Rainer Hahnekamp's NgRx Best Practices series provides one discussion of the approach, as do teams running large Nx-style workspaces. Evaluate it on evidence and tradeoffs, not as blessed doctrine.

Here is the shape, once, concretely. Then we return to the argument.

import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Observable } from 'rxjs';
import { ordersActions } from './orders.actions';
import {
  selectAllOrders,
  selectAttentionCount,
  selectFilteredOrders,
  selectOrdersError,
  selectOrdersLoading,
  selectOrderStatuses,
  selectOverdueCount,
} from './orders.selectors';
import type { Order, OrderStatus } from './order.model';

@Injectable({ providedIn: 'root' })
export class OrdersFacade {
  private readonly store = inject(Store);

  readonly orders$: Observable<readonly Order[]> =
    this.store.select(selectAllOrders);
  readonly filteredOrders$: Observable<readonly Order[]> =
    this.store.select(selectFilteredOrders);
  readonly loading$: Observable<boolean> =
    this.store.select(selectOrdersLoading);
  readonly error$: Observable<string | null> =
    this.store.select(selectOrdersError);
  readonly statuses$: Observable<readonly OrderStatus[]> =
    this.store.select(selectOrderStatuses);
  readonly overdueCount$: Observable<number> =
    this.store.select(selectOverdueCount);
  readonly attentionCount$: Observable<number> =
    this.store.select(selectAttentionCount);

  load(): void {
    this.store.dispatch(ordersActions.loadOrders());
  }

  setStatusFilter(status: OrderStatus | 'all'): void {
    this.store.dispatch(ordersActions.setStatusFilter({ status }));
  }

  updateStatus(id: string, status: OrderStatus): void {
    this.store.dispatch(ordersActions.updateStatus({ id, status }));
  }
}

The component on the other side of the boundary shrinks to this:

import { Component, inject, OnInit } from '@angular/core';
import { OrdersFacade } from '../data-access';
import { OrderStatus } from '../data-access';

@Component({
  selector: 'app-order-list',
  templateUrl: './order-list.component.html',
})
export class OrderListComponent implements OnInit {
  private readonly orders = inject(OrdersFacade);

  readonly orders$ = this.orders.filteredOrders$;
  readonly loading$ = this.orders.loading$;
  readonly error$ = this.orders.error$;
  readonly statuses$ = this.orders.statuses$;
  readonly overdueCount$ = this.orders.overdueCount$;
  readonly attentionCount$ = this.orders.attentionCount$;

  ngOnInit(): void {
    this.orders.load();
  }

  setStatusFilter(status: OrderStatus | 'all'): void {
    this.orders.setStatusFilter(status);
  }

  updateStatus(id: string, status: OrderStatus): void {
    this.orders.updateStatus(id, status);
  }
}

No Store, no actions, no selectors.

A few structural notes, briefly, because they are the difference between a boundary and a junk drawer. The facade lives in the feature's data layer. The feature's public barrel exports the facade, its provider function, and the domain types consumers need. Actions, reducers, selectors, and effects stay private:

// orders/data-access/index.ts - the feature's public API
export { OrdersFacade } from './orders.facade';
export { provideOrdersDataAccess } from './orders.providers';
export type { Order, OrderStatus } from './order.model';
// actions, reducers, selectors, and effects are intentionally NOT exported

For teams that want enforcement rather than convention, Nx or Sheriff dependency rules can forbid feature code from reaching past the barrel into the data layer's internals. The barrel declares the intended public API; the dependency rule enforces it. That enforcement is what makes the boundary survive contact with the developer who has never been burned yet. One shape, and we move on.

Request flow through the facade with API-first semantics

API-first request flow: the component touches the facade, the data layer handles everything between.

Naming and granularity. One facade per feature, named after the domain. OrdersFacade, not OrdersStoreFacade, and definitely not AppFacade. A facade that wraps three features is a god object wearing a trench coat.

The wins nobody talks about

The obvious wins are settled. These are the ones that only show up after living with the pattern for a while.

The migration seam. Keep the facade API stable and swap what sits behind it, from the classic store to SignalStore, without touching a component. The SignalStore section demonstrates it fully. A stable boundary turns a rewrite into a swap.

Mixed data sources behind one API. Not every read belongs in the store, and deciding where a read comes from is the facade's business. The dashboard has a KPI card: orders needing attention. Initially the facade derives it from the store. Later the backend adds a reporting endpoint and the facade switches to it. The component should not care which.

// Initially: a store-derived selector.
readonly attentionCount$: Observable<number> =
  this.store.select(selectAttentionCount);
// Later: a reporting API, after the derivation got expensive client-side.
private readonly reportingApi = inject(ReportingApiService);

readonly attentionCount$: Observable<number> =
  this.reportingApi.attentionCount();

The component did not change.

One rule guards this whole section: if a method is not part of the feature's state API, it does not belong here. No CSV exports, no utility methods, no junk drawer with a respectable name. To be precise: a facade may delegate to data services, but transport details, caching, and business policy belong in the underlying layer. The facade decides which source answers. It does not become the source.

Orchestration that selectors cannot hold. A selector is a pure derivation. It cannot dispatch an action. A facade method can, which is why loading stays an explicit command: load(), or an ensureLoaded() guard for call sites that might run twice. The facade deliberately does not hide that orchestration inside a property getter. A reader should be able to see where data comes from, and a dispatch hiding in a getter breaks that rule.

The case against

A senior reader has objections loaded by now. They deserve their full due, because the pattern is only worth using where it survives them.

Small features may not need a separate facade. A feature with two selectors and one consumer component gains little from a facade. The service becomes a pass-through layer, a file you maintain for the ceremony of it. Introduce the pattern where the pain is, not on day one for every feature.

Good hygiene can substitute. If every container reads through one composed view-model selector and internal actions never leak into components, the store surface is already small. A facade then formalizes what discipline achieved, which may be the cheaper option for a disciplined small team. The facade's advantage is that it survives the week discipline slips.

Consider the lighter tool. If the state is local to a single feature, @ngrx/component-store or a feature-scoped SignalStore may be the entire answer. No global store, no facade, no extra layer. And there is a harder question worth asking honestly. If you find yourself wrapping the Redux pattern in services because you dislike the pattern, the answer is probably a service-based approach, not a prettier wrapper around the thing you dislike.

Facades get abused without rules. The failure modes are well documented at this point. Side effects creep into facade methods: transport details here, an ad-hoc cache there. The facade grows a second feature, then a third, and suddenly it is a god object. The pattern needs team rules the way a public API needs a contract: facades stay thin, no business logic, one feature each. Write them down, because unwritten rules are wishes. The teams that do this keep their facades thin for years. The teams that do not get the god object, and then they blame the pattern. For reference, here is what the abuse looks like. Do not write this.

// ANTI-PATTERN. Do not do this.
@Injectable({ providedIn: 'root' })
export class AppFacade {
  private readonly store = inject(Store);
  private readonly cache = new Map<string, unknown>(); // ad-hoc HTTP cache

  refreshEverything(): void {
    // orders, products, and auth state walk into a service.
    // a setTimeout walks in after them.
    setTimeout(() => {
      this.store.dispatch(ordersActions.loadOrders());
      this.store.dispatch(productsActions.loadProducts());
    }, 0);
  }
}

SignalStore changes the equation

Everything so far assumed the classic store: actions, reducers, selectors, effects. @ngrx/signals rearranges the furniture, so the honest question is whether the abstraction we built around NgRx still makes sense.

What changed. signalStore() gives you an injectable service assembled from withState, withComputed, and withMethods. State is read through signals and consumed in templates as store.orders(). Consumers cannot mutate state except through the store's methods. There is no separate selector layer to import and call. Derived state lives alongside the store through withComputed. The store is already service-shaped.

What disappears is real: the action group file, the selector file, the effects class all fold into withMethods and withComputed. What remains is the war story's question. The component still needs to know something. The only question is how much, and from whom.

The honest answer. For a new feature, design the SignalStore itself as the public feature API. Add a wrapper only when it provides a distinct consumer contract or compatibility requirement, the way Situation A keeps the facade as a compatibility boundary during migration. If your 2026 NgRx code is all SignalStore, you may never write a facade class again. That sentence should feel like a relief, not a threat.

But it is not the end of the argument, because the facade was never really about NgRx. It is about defining a boundary between state internals and the feature's public API. SignalStore moved the line. It did not erase it. Selectors and actions may disappear as SignalStore spreads, but the question remains: what is this component allowed to know? Put differently: where should knowledge about state management stop?

That reframing splits the answer into two situations.

Situation A: an existing classic NgRx feature being migrated. Keep the facade as a compatibility boundary and replace its internals with SignalStore. Components remain unchanged. Boring migrations ship.

The migration is almost anticlimactic: you write the SignalStore, rewire the facade's private fields, and run the test suite. The components keep their existing API, because every assumption about the store now lives in the one file you are rewriting.

Situation B: a brand-new SignalStore feature. Expose the SignalStore directly, designed as the feature's public API. Only add a wrapper for a distinct consumer contract or a compatibility requirement.

The migration seam: stable facade API over swappable store engines

The engine behind the facade is replaceable. The contract above it is not.

Here is the same public surface over a SignalStore:

import { computed, inject } from '@angular/core';
import { patchState, signalStore, type, withComputed, withMethods, withState } from '@ngrx/signals';
import { setAllEntities, updateEntity, withEntities } from '@ngrx/signals/entities';
import { firstValueFrom } from 'rxjs';
import type { Order, OrderStatus } from './order.model';
import { OrdersApiService } from './orders-api.service';

type OrdersState = {
  loading: boolean;
  error: string | null;
  statusFilter: OrderStatus | 'all';
};

export const OrdersStore = signalStore(
  { providedIn: 'root' },
  withState<OrdersState>({ loading: false, error: null, statusFilter: 'all' }),
  withEntities({ entity: type<Order>(), collection: 'order' }),
  withComputed(({ orderEntities, statusFilter }) => ({
    filteredOrders: computed(() => {
      const filter = statusFilter();
      const orders = orderEntities();
      return filter === 'all'
        ? orders
        : orders.filter((order) => order.status === filter);
    }),
    attentionCount: computed(
      () =>
        orderEntities().filter(
          (o) => o.status === 'overdue' || o.status === 'paymentFailed'
        ).length
    ),
    statuses: computed(() => [
      ...new Set(orderEntities().map((o) => o.status)),
    ]),
    overdueCount: computed(
      () => orderEntities().filter((o) => o.dueDate < Date.now()).length
    ),
  })),
  withMethods((store, ordersApi = inject(OrdersApiService)) => ({
    async load(): Promise<void> {
      patchState(store, { loading: true, error: null });
      try {
        const orders = await firstValueFrom(ordersApi.list());
        patchState(store, setAllEntities([...orders], { collection: 'order' }));
      } catch {
        patchState(store, { error: 'Could not load orders.' });
      } finally {
        patchState(store, { loading: false });
      }
    },
    setStatusFilter(status: OrderStatus | 'all'): void {
      patchState(store, { statusFilter: status });
    },
    async updateStatus(id: string, status: OrderStatus): Promise<void> {
      patchState(store, { error: null });
      try {
        const order = await firstValueFrom(ordersApi.updateStatus(id, status));
        patchState(
          store,
          updateEntity({ id, changes: { status: order.status } }, { collection: 'order' })
        );
      } catch {
        patchState(store, { error: 'Could not update the order.' });
      }
    },
  }))
);

One deliberate choice in that example: expected API failures are caught inside the store and exposed as error state. Each new attempt clears the previous error, so a successful retry clears the failure that preceded it, and the facade's fire-and-forget void calls are safe. Returning the promise for callers to handle can be a deliberate part of a public API; here the store owns its failures instead.

One style note, because this section will be read closely. For simple request/response flows, async/await is the straightforward choice. rxMethod earns its place when the interaction needs RxJS stream semantics: cancellation, debouncing, switching. There is little to debounce or cancel here, so the plain async method is the right shape. Repeated rapid loads can still race; if that matters, reach for rxMethod.

The facade keeps its exact public API and swaps its engine:

import { Injectable, inject } from '@angular/core';
import { toObservable } from '@angular/core/rxjs-interop';
import { Observable } from 'rxjs';
import { OrdersStore } from './orders.store';
import type { Order, OrderStatus } from './order.model';

@Injectable({ providedIn: 'root' })
export class OrdersFacade {
  private readonly store = inject(OrdersStore);

  readonly orders$: Observable<readonly Order[]> =
    toObservable(this.store.orderEntities);
  readonly filteredOrders$: Observable<readonly Order[]> =
    toObservable(this.store.filteredOrders);
  readonly loading$: Observable<boolean> =
    toObservable(this.store.loading);
  readonly error$: Observable<string | null> =
    toObservable(this.store.error);
  readonly statuses$: Observable<readonly OrderStatus[]> =
    toObservable(this.store.statuses);
  readonly overdueCount$: Observable<number> =
    toObservable(this.store.overdueCount);
  readonly attentionCount$: Observable<number> =
    toObservable(this.store.attentionCount);

  load(): void {
    // Safe to fire and forget: expected API failures are caught
    // inside the store and exposed as error state.
    void this.store.load();
  }

  setStatusFilter(status: OrderStatus | 'all'): void {
    this.store.setStatusFilter(status);
  }

  updateStatus(id: string, status: OrderStatus): void {
    void this.store.updateStatus(id, status);
  }
}

And the component, in both worlds:

import { Component, inject, OnInit } from '@angular/core';
import { OrdersFacade } from '../data-access';
import { OrderStatus } from '../data-access';

@Component({
  selector: 'app-order-list',
  templateUrl: './order-list.component.html',
})
export class OrderListComponent implements OnInit {
  private readonly orders = inject(OrdersFacade);

  readonly orders$ = this.orders.filteredOrders$;
  readonly loading$ = this.orders.loading$;
  readonly error$ = this.orders.error$;
  readonly statuses$ = this.orders.statuses$;
  readonly overdueCount$ = this.orders.overdueCount$;
  readonly attentionCount$ = this.orders.attentionCount$;

  ngOnInit(): void {
    this.orders.load();
  }

  setStatusFilter(status: OrderStatus | 'all'): void {
    this.orders.setStatusFilter(status);
  }

  updateStatus(id: string, status: OrderStatus): void {
    this.orders.updateStatus(id, status);
  }
}

That component file is identical whether the facade wraps the classic store or a SignalStore. The component knows about orders. It does not know how state is queried, how changes are requested, or which technology provides them.

A final caveat on the migration. Components can retain their existing API, but toObservable bridges signals into RxJS through an asynchronous effect, and multiple rapid signal updates may collapse into a single emission. The public types and method signatures are preserved. The behavior is equivalent at the API level, not bit-for-bit identical.

For Situation B, the component simply injects the store directly:

export class OrderListComponent implements OnInit {
  private readonly store = inject(OrdersStore);

  readonly orders = this.store.filteredOrders;
  readonly loading = this.store.loading;
  readonly error = this.store.error;

  ngOnInit(): void {
    void this.store.load();
  }
}

The recommendation, stated plainly. New features default to SignalStore, designed as the public feature API, with no extra wrapper unless it provides a distinct consumer contract. Existing classic-store features get a facade when they are large, shared, or migration candidates.

Notice the asymmetry, because it is the whole decision in miniature. Situation A pays for the boundary once and collects twice: in day-to-day decoupling, and again at migration time. Situation B needs no extra wrapper because the store itself is the public feature API. The mistake is applying one situation's answer to the other's question.

Should this feature get a facade?

Triggers first. Multiple components depend on the same feature state. Three or more consumers is a useful warning sign that the feature API is becoming shared infrastructure, but treat it as a heuristic, not a rule. Two deeply coupled consumers can justify a facade while three trivial ones may not. A store refactor you are afraid of is a trigger, and so is a planned migration.

One more trigger is organizational. If two teams share a feature's state and each has opinions about how the store should work, the facade draws the line. Neither team touches the other's internals. The public API is the agreed surface, and arguments about state shape happen in one file instead of in every pull request.

Then the rules, written down where the team can see them. One facade per feature. For a classic-store facade, keep the public surface to observable reads and domain-oriented methods. No business logic inside; orchestration at most. Internal actions stay out of the public barrel. The facade is reviewed as a public API, with breaking changes called out in review. Mandating the pattern everywhere breeds god objects.

The checklist:

  • [ ] Multiple components depend on the same feature state

  • [ ] The feature uses classic @ngrx/store (not SignalStore)

  • [ ] You can name the public API in one breath (reads and writes)

  • [ ] There is a plausible future where the internals change (migration, split, simplification)

  • [ ] The team agrees on the facade rules and will review it as a public API

Use these checks to answer one question: would a stable feature API materially reduce coupling across consumers or make a planned implementation change easier? If the answer is not a clear yes, skip the facade for now.

The boundary survives

The facade was never really about NgRx. It is about defining a boundary between state internals and the feature's public API. SignalStore changed where the boundary sits, not whether you need one.

Which brings us back to the question from the war story. How much does this component need to know about NgRx at all? As little as possible, and ideally nothing. Not because NgRx is bad, but because the component's job is orders, and everything else is someone else's job.