+1 (726) 227-3745

State Management for a MEAN Front End: NgRx SignalStore in Angular 22

Most MEAN front ends we inherit have the same problem. State lives in whatever component asked for it first. One component fetches /api/projects, another fetches it again on a different route, a third keeps a stale copy in a BehaviorSubject, and nobody can say which one is right after a user edits a record in a dialog. Angular 22's signals fixed change detection, but they did not, on their own, give teams a place to put shared state.

NgRx SignalStore is that place. It is a small, signal-native store: no actions, no reducers, no effects boilerplate, just typed state, computed selectors and methods. This tutorial builds a realistic feature store for a MEAN app — server data from an Express 5 API, entity collections, optimistic updates with rollback, and request de-duplication — and shows where it belongs relative to httpResource and plain services.

Versions used: Angular 22, @ngrx/signals 20, Express 5, Mongoose 8 on MongoDB 8, Node 24 LTS.

When you need a store (and when you do not)

Be honest about this before installing anything. We use three tiers on client projects:

  1. Component signals. State used by one component and its template. A signal('') for a search box. Never promote this.
  2. httpResource / resource. Read-only server data keyed by a signal, where the component is the only consumer and refetching on key change is the desired behaviour. Angular gives you loading and error signals for free.
  3. SignalStore. State shared by several components or routes, with writes: lists you mutate, selections, filters that must survive navigation, optimistic UI, or anything where two screens must never disagree.

If your feature is tier 1 or 2, stop here. Reaching for a store too early is how MEAN front ends end up with 400 lines of ceremony around one GET request.

The API side

Assume a conventional Express 5 resource. Nothing exotic, but two details matter for the client.

// api/src/routes/projects.js
import { Router } from 'express';
import { z } from 'zod';
import { Project } from '../models.js';
import { requireAuth } from '../auth.js';

export const projects = Router();
projects.use(requireAuth);

const ProjectBody = z.object({
  name: z.string().min(1).max(120),
  status: z.enum(['active', 'paused', 'archived']),
});

projects.get('/', async (req, res) => {
  const { status, q } = req.query;
  const filter = { owner: req.user.sub };
  if (status) filter.status = status;
  if (q) filter.name = { $regex: new RegExp(escapeRegex(String(q)), 'i') };
  res.json(await Project.find(filter).sort({ updatedAt: -1 }).limit(200).lean());
});

projects.post('/', async (req, res) => {
  const body = ProjectBody.parse(req.body);
  res.status(201).json(await Project.create({ ...body, owner: req.user.sub }));
});

projects.patch('/:id', async (req, res) => {
  const body = ProjectBody.partial().parse(req.body);
  const doc = await Project.findOneAndUpdate(
    { _id: req.params.id, owner: req.user.sub },
    { $set: body },
    { new: true },
  );
  if (!doc) return res.status(404).json({ error: 'not found' });
  res.json(doc);
});

First, every query is scoped by owner, so an optimistic client can never talk itself into showing another tenant's row. Second, PATCH returns the full updated document. Optimistic updates are far easier to reconcile when the server hands back the authoritative record instead of 204 No Content.

Installing and shaping the store

npm install @ngrx/signals@20

Start with state, computed values and the loading flags. withEntities gives you a normalised entityMap and an ids array, so lookups by id are O(1) and list order stays explicit.

// client/src/app/projects/projects.store.ts
import { computed, inject } from '@angular/core';
import {
  signalStore, withState, withComputed, withMethods, withHooks, patchState,
} from '@ngrx/signals';
import { withEntities, setAllEntities, setEntity, removeEntity, entityConfig } from '@ngrx/signals/entities';
import { rxMethod } from '@ngrx/signals/rxjs-interop';
import { pipe, switchMap, tap, debounceTime, distinctUntilChanged } from 'rxjs';
import { tapResponse } from '@ngrx/operators';
import { ProjectsApi, Project } from './projects.api';

type Filter = { status: 'all' | Project['status']; q: string };

type ProjectsState = {
  filter: Filter;
  loading: boolean;
  error: string | null;
  selectedId: string | null;
};

const initial: ProjectsState = {
  filter: { status: 'all', q: '' },
  loading: false,
  error: null,
  selectedId: null,
};

const projectConfig = entityConfig({
  entity: type<Project>(),
  selectId: (p: Project) => p._id,
});

export const ProjectsStore = signalStore(
  { providedIn: 'root' },
  withState(initial),
  withEntities(projectConfig),
  withComputed(({ entities, entityMap, selectedId, filter }) => ({
    selected: computed(() => {
      const id = selectedId();
      return id ? entityMap()[id] ?? null : null;
    }),
    activeCount: computed(() => entities().filter(p => p.status === 'active').length),
    isFiltered: computed(() => filter().status !== 'all' || filter().q.trim() !== ''),
  })),
  withMethods((store, api = inject(ProjectsApi)) => ({
    setFilter(patch: Partial<Filter>) {
      patchState(store, state => ({ filter: { ...state.filter, ...patch } }));
    },
    select(id: string | null) {
      patchState(store, { selectedId: id });
    },
    load: rxMethod<Filter>(
      pipe(
        debounceTime(250),
        distinctUntilChanged((a, b) => a.status === b.status && a.q === b.q),
        tap(() => patchState(store, { loading: true, error: null })),
        switchMap(filter =>
          api.list(filter).pipe(
            tapResponse({
              next: rows => patchState(store, setAllEntities(rows, projectConfig), { loading: false }),
              error: (err: Error) => patchState(store, { loading: false, error: err.message }),
            }),
          ),
        ),
      ),
    ),
  })),
  withHooks({
    onInit(store) {
      store.load(store.filter);
    },
  }),
);

Three things to notice.

rxMethod accepts a signal. Passing store.filter in onInit subscribes the loader to the filter signal, so every filter change refetches — debounced, de-duplicated by distinctUntilChanged, and cancelled mid-flight by switchMap. That single switchMap removes an entire class of bug where a slow response for filter A overwrites the result for filter B.

tapResponse keeps errors from killing the subscription. A plain catchError that forgets to re-emit will silently stop the store from ever loading again; that failure mode is one of the most common we find during front-end audits.

{ providedIn: 'root' } makes this a singleton. For a store that should die with a route, drop that option and add the store to the route's providers array instead.

Optimistic writes with rollback

Optimistic UI is where a store earns its keep, because the rollback needs the previous entity and only the store has it.

    async rename(id: string, name: string) {
      const previous = store.entityMap()[id];
      if (!previous) return;
      patchState(store, setEntity({ ...previous, name }, projectConfig));
      try {
        const saved = await firstValueFrom(api.patch(id, { name }));
        patchState(store, setEntity(saved, projectConfig)); // reconcile with server truth
      } catch (err) {
        patchState(store, setEntity(previous, projectConfig), { error: 'Rename failed' });
      }
    },

    async create(draft: Omit<Project, '_id'>) {
      const tempId = `tmp_${crypto.randomUUID()}`;
      patchState(store, setEntity({ ...draft, _id: tempId } as Project, projectConfig));
      try {
        const saved = await firstValueFrom(api.create(draft));
        patchState(store, removeEntity(tempId), setEntity(saved, projectConfig));
      } catch {
        patchState(store, removeEntity(tempId), { error: 'Could not create project' });
      }
    },

Two rules we enforce in review:

  • Always reconcile with the server response, never with your local guess. The server may normalise the name, set updatedAt, or apply a business rule. Writing back saved keeps the client honest.
  • Never leave a temp id in the list on failure. A tmp_ row that survives an error will be sent to PATCH /api/projects/tmp_... on the user's next edit and 404 or, worse, cast-error your API.

If the write must survive a page reload or a dropped connection, an optimistic patch is not enough; you want a durable outbox, which we covered in offline-first MEAN apps.

Consuming the store in a component

@Component({
  selector: 'app-projects',
  template: `
    <input
      [value]="store.filter().q"
      (input)="store.setFilter({ q: $any($event.target).value })"
      placeholder="Search projects" />

    @if (store.loading()) {
      <p role="status">Loading…</p>
    } @else if (store.error(); as error) {
      <p role="alert">{{ error }}</p>
    } @else {
      <p>{{ store.activeCount() }} active</p>
      <ul>
        @for (project of store.entities(); track project._id) {
          <li [class.pending]="project._id.startsWith('tmp_')">
            <button (click)="store.select(project._id)">{{ project.name }}</button>
          </li>
        } @empty {
          <li>{{ store.isFiltered() ? 'No matches.' : 'No projects yet.' }}</li>
        }
      </ul>
    }
  `,
})
export class Projects {
  readonly store = inject(ProjectsStore);
}

The component holds no state at all. That is the point: a second component on another route can inject the same store and will see the rename the instant it happens, with no event bus and no refetch.

Because the store exposes plain signals, this works under zoneless change detection with no extra wiring, and it composes with @defer blocks — the store is already populated when a deferred chunk finally renders.

Testing the store

Stores are ordinary injectables, so tests need no component harness and no zone.js.

import { TestBed } from '@angular/core/testing';
import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { ProjectsStore } from './projects.store';

describe('ProjectsStore', () => {
  let http: HttpTestingController;
  let store: InstanceType<typeof ProjectsStore>;

  beforeEach(() => {
    TestBed.configureTestingModule({
      providers: [provideHttpClient(), provideHttpClientTesting()],
    });
    http = TestBed.inject(HttpTestingController);
    store = TestBed.inject(ProjectsStore);
  });

  it('rolls a failed rename back to the previous name', async () => {
    store.load({ status: 'all', q: '' });
    http.expectOne(r => r.url.endsWith('/api/projects'))
        .flush([{ _id: 'p1', name: 'Atlas', status: 'active' }]);

    const pending = store.rename('p1', 'Atlas v2');
    expect(store.entityMap()['p1'].name).toBe('Atlas v2'); // optimistic
    http.expectOne('/api/projects/p1').flush(null, { status: 500, statusText: 'Server Error' });
    await pending;

    expect(store.entityMap()['p1'].name).toBe('Atlas');
    expect(store.error()).toBe('Rename failed');
  });
});

Run these under Vitest if you have already made that move — see migrating Angular unit tests from Karma to Vitest. Optimistic-path tests are the highest-value unit tests in a signals front end, because rollback bugs are almost invisible in manual QA.

Migrating an existing NgRx or BehaviorSubject front end

Nobody has the budget to rewrite state management in one sprint. The route we use with clients:

  1. Inventory the shared state. List every BehaviorSubject, ReplaySubject and NgRx feature slice, and mark each as tier 1, 2 or 3 from the section above. Most codebases discover that a third of their store is single-component state that can simply become a signal.
  2. Migrate one feature at a time. SignalStore and classic NgRx coexist in the same application; there is no big-bang switch. Pick the feature with the most cross-component reads.
  3. Replace the subject, not the component. Keep the old service's public method names, reimplement them over the store, and let components migrate later. This keeps each PR small and reviewable.
  4. Delete the RxJS pipeline last. Once nothing subscribes to the old subject, remove it in a separate commit so a revert is trivial.
  5. Guard tenancy at the API, not the store. A store consolidating data across routes makes it much easier to accidentally render data the user should not see. Server-side scoping is the control that matters — see multi-tenant SaaS on the MEAN stack.

Common mistakes we see in review

  • Putting server responses in the store verbatim, including __v and nested populated documents. Map to a client type in the API service. Your store is not a cache of Mongoose documents.
  • A single god store for the whole app. One store per feature. Cross-store reads via inject() inside withComputed are fine and are cheaper than merging everything.
  • Calling patchState inside a computed. Computed values must be pure; write state in methods or rxMethod only.
  • Forgetting the unauthenticated path. On logout, reset the store (patchState(store, initial, removeAllEntities())) or the next user sees the previous user's list for one frame.
  • Using a store where httpResource would do. Reviewers should push back on any store with no writes and one consumer.

Where this fits

Signal-based state management is the piece that makes an Angular 22 front end maintainable at team size, and it is usually the cheapest improvement available on an inherited codebase: no server changes, no data migration, no downtime. If you are staring at a front end where nobody trusts the data on screen, our performance tuning and MEAN stack consulting teams do exactly this kind of incremental cleanup, and our AngularJS to Angular migration service picks up the older cases. Get in touch with a rough description of your front end and we will tell you which tier your state actually belongs in.