NgRx State Rehydration: Load, Replay, and Debug Like a Pro
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.
- Allow
- 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:
- Capture Error: A production error triggers a call to
saveSnapshot()in an effect or global handler. - Transmit State: The serialized snapshot is stored and transmitted to another service, such as Datadog, for retrieval later.
- Local Dev: You retrieve the snapshot and load it into your local application, pasting it into
localStoragemanually. - 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
| Capability | How It Works | Why It Matters |
|---|---|---|
| State Snapshotting | Serializes the entire NgRx store using firstValueFrom(store) and saves it to localStorage | Captures the exact app state when a bug or anomaly occurs |
| Persistence & Replay | Stored snapshot is dispatched back into the store via a custom meta reducer during app bootstrapping | Enables deterministic reproduction of bugs across environments |
| Error-Aware Debugging | Trigger saveSnapshot() inside a global error handler or effect, and send the snapshot to Datadog or other tools | Turns vague crash reports into actionable, replayable bug reports |
| State-Driven Testing | Developers or test runners can preload snapshots into localStorage before app initialization | Makes local and automated testing more realistic and scenario-specific |
| Bootstrap Integration | Uses APP_INITIALIZER to preload snapshots at startup, and META_REDUCERS to handle rehydration | Requires zero developer input at runtime; seamless DX in dev mode |
| Guardrails | Snapshot logic gated behind isDevMode() and optionally versioned | Avoids 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.