Skip to content
Briebug

Developer Resource Center

Sharing our recent findings, expertise, and insights with the community.

NgRx State Rehydration: Load, Replay, and Debug Like a Pro

Angular NgRx Frontend by 6 min read

A pattern for capturing a snapshot of the NgRx store at runtime, persisting it, and reloading it later to reproduce production bugs locally or drive deterministic tests.

When errors occur in complex applications, reproducing the exact state that triggered the issue is often half the battle. If you’re using NgRx in Angular, capturing and replaying application state can be a powerful debugging tool—especially when integrated with observability platforms like Datadog. This article outlines a pattern for capturing a snapshot of the store at runtime, persisting it, and reloading it later for local development or test reproduction.


Why This Pattern Matters

Problem: You encounter a critical error in production, but the conditions that led to it are hard to reproduce. You see the error log in Datadog, but without the application state that caused it, you’re stuck guessing.

Solution: Capture a full snapshot of the NgRx store when the error occurs, send it to a service like Datadog, and load it in your local dev environment to reproduce the issue precisely.

This pattern helps you shorten debugging cycles and validate fixes with real user state.

The Core Pattern

1. Store Snapshot Service

The StorePreloaderService encapsulates two responsibilities:

  • saveSnapshot(): Serialize the full NgRx store and persist it to localStorage.
  • loadSnapshot(): Parse the saved snapshot and dispatch an action to load it into the application.
export const storageSnapshotKey = 'ngrx-error-snapshot';

export class StorePreloaderService {
  store = inject(Store);
  window = inject(DOCUMENT).defaultView;

  loadSnapshot(): void {
    const savedState = this.window?.localStorage.getItem(storageSnapshotKey);
    if (!savedState) {
      console.log('No store snapshot found.');
      return;
    }

    try {
      const parsedState = JSON.parse(savedState);
      this.store.dispatch(loadSnapshot({ snapshot: parsedState }));
    } catch (e) {
      console.error('Failed to load store', e);
    }
  }

  async saveSnapshot(): Promise<void> {
    try {
      const state = await firstValueFrom(this.store);
      const snapshot = JSON.stringify(state);
      this.window?.localStorage.setItem(storageSnapshotKey, snapshot);
      console.log('NgRx Store Snapshot:', JSON.stringify(state, null, 2));
    } catch (e) {
      console.error('Failed to save error snapshot', e);
    }
  }
}

Best practice: Call saveSnapshot() in your global error handler or error effect when a critical failure occurs.


2. Custom Meta Reducer

NgRx doesn’t offer a built-in way to replace the entire state tree. So you’ll need a meta reducer to intercept the loadSnapshot action and return the serialized state as-is.

export const loadSnapshot = createAction('[App] Load Snapshot', props<{ snapshot: AppState }>());
export function loadSnapshotMetaReducer() {
  return (reducer: any) => (state: any, action: Action) => {
    if (action?.type === loadSnapshot?.type) {
      // replace the entire state tree
      return (action as ReturnType<typeof loadSnapshot>)?.snapshot;
    }

    return reducer(state, action);
  };
}

Add this meta reducer only when snapshotting is enabled (typically by using isDevMode() from Angular).

Important Note About isDevMode()

Controlling when snapshotting and rehydration are enabled is critical. Angular’s isDevMode() is a convenient toggle, but it may not be sufficient for every use case.

  • Separation of Concerns: You may want to allow capturing and transmitting error snapshots in production (to an error logging service) while preventing loading or overriding NgRx state in production.
  • Granular Control: Instead of one global flag, gate each capability (capture, persist, load, replay) with its own guard. For example:
    • Allow saveSnapshot() in both dev and prod for observability.
    • Restrict loadSnapshot() and meta reducer injection to dev/test environments only.
  • Environment-Aware Configuration: isDevMode() works well locally, but in CI/CD pipelines or secure staging environments, you may need more fine-grained checks (e.g., custom environment variables, build flags, or feature toggles).

Recommendation: Treat snapshot rehydration as a developer-only tool. Use isDevMode() or equivalent environment gating to ensure it never unintentionally runs in production user flows.

3. Bootstrap-Time Integration

You’ll want to preload the snapshot as early as possible in the application lifecycle. APP_INITIALIZER is perfect for this.

export const provideLoadSnapshotData = ({ allowSnapshot }: { allowSnapshot: boolean }) =>
  provideAppInitializer(() => {
    if (allowSnapshot) {
      console.log('Development mode detected. Loading NgRx snapshot...');
      const preloader = inject(StorePreloaderService);
      preloader.loadSnapshot();
    }
  });

export const provideSnapshotMetaReducer = ({ allowSnapshot }: { allowSnapshot: boolean }) => {
  const providers: Provider[] = [];

  if (allowSnapshot) {
    providers.push({
      provide: META_REDUCERS,
      useFactory: loadSnapshotMetaReducer, // Custom Meta Reducer from step 2
      multi: true,
    });
  }

  return makeEnvironmentProviders(providers);
};

Enable loading the snapshot data and the meta reducer conditionally in your app config:

export const appConfig: ApplicationConfig = {
  providers: [
    provideStore(),
    provideLoadSnapshotData({ allowSnapshot: isDevMode() }),
    provideSnapshotMetaReducer({ allowSnapshot: isDevMode() }),
  ]
}

4. Replay Workflow

Here’s how the full workflow plays out:

  1. Capture Error: A production error triggers a call to saveSnapshot() in an effect or global handler.
  2. Transmit State: The serialized snapshot is stored and transmitted to another service, such as Datadog, for retrieval later.
  3. Local Dev: You retrieve the snapshot and load it into your local application, pasting it into localStorage manually.
  4. Replay: The app detects the snapshot on init and injects it into the store automatically.

Use Case: Debugging with Datadog

To integrate this pattern with Datadog:

this.datadogRum.addError(error, {
  snapshot: await this.storePreloaderService.saveSnapshot(), // refactor to return the snapshot
  route: this.router.url,
  // other context
});

In development, copy the snapshot from Datadog logs and paste it into localStorage using your browser console:

localStorage.setItem('ngrx-store-snapshot', '<PASTED_JSON>');

Then reload the app and voila—you’re transported into the exact state where the bug occurred.

Use Case: Replay in Unit or E2E Tests

You can also integrate this pattern into Cypress or Playwright tests. For example:

cy.window().then(win => {
  win.localStorage.setItem('ngrx-store-snapshot', JSON.stringify(testSnapshot));
});
cy.visit('/');

This allows a deterministic test setup without mocking a complex series of actions or backend responses.

Considerations & Caveats

  • Snapshot size: Large stores will bloat localStorage, which has a ~5MB limit. Place limits or filters on certain slices of state.
  • Volatile state: Avoid snapshotting transient UI state (e.g., toasts, modals).
  • Schema mismatch: App updates may break old snapshots. Add versioning to your snapshot payload if needed.
  • PII: Snapshots may include Personally Identifiable Information (PII) or other sensitive data. Before persisting or transmitting state, audit the payload and redact or exclude confidential fields. Never capture authentication tokens, passwords, or user input blindly—especially in production environments.

Summary

CapabilityHow It WorksWhy It Matters
State SnapshottingSerializes the entire NgRx store using firstValueFrom(store) and saves it to localStorageCaptures the exact app state when a bug or anomaly occurs
Persistence & ReplayStored snapshot is dispatched back into the store via a custom meta reducer during app bootstrappingEnables deterministic reproduction of bugs across environments
Error-Aware DebuggingTrigger saveSnapshot() inside a global error handler or effect, and send the snapshot to Datadog or other toolsTurns vague crash reports into actionable, replayable bug reports
State-Driven TestingDevelopers or test runners can preload snapshots into localStorage before app initializationMakes local and automated testing more realistic and scenario-specific
Bootstrap IntegrationUses APP_INITIALIZER to preload snapshots at startup, and META_REDUCERS to handle rehydrationRequires zero developer input at runtime; seamless DX in dev mode
GuardrailsSnapshot logic gated behind isDevMode() and optionally versionedAvoids polluting prod behavior or loading incompatible snapshots

This snapshot and hydration pattern for NgRx provides a pragmatic, low-friction way to capture, persist, and replay application state across environments. By combining a custom meta reducer with bootstrapped snapshot loading and strategic use of localStorage, it turns real-world errors into reproducible test cases—no mocks or backend setups required. This approach gives you precise control over your app’s runtime context. It’s simple to implement, easy to scale, and makes your NgRx-powered app dramatically easier to debug, test, and support.