How to Keep NgRx State on Refresh

Nils Mehlhorn - Nov 17 '20 - - Dev Community

Contents
Persist with Redux DevTools
Re-Hydration Meta-Reducer
Re-Hydration Meta-Reducer + Effects

It's a common requirement: persisting NgRx state in order to load it back up when your Angular application is restarted. This process of populating an empty object with domain data is called re-hydration. While it's common to persist the store data to the browser storage (mostly localStorage), you might also re-hydrate from a server-side cache.

๐Ÿ“• I've written a book on NgRx. Learn how to structure your state, write testable reducers and work with actions and effects from one well-crafted resource.

There are some pitfalls to watch out for when applying this pattern. For one thing, you should take care not to store sensitive data in potentially insecure storages. Consider factors such as multiple users working on the same machine. Additionally, the state you're storing can become outdated. Consequently, you might incorporate techniques like validation and partial re-hydration.

Also, keep in mind that the shape of your application state can change between different releases. Meanwhile, your clients will have old versions in their storage - carelessly re-hydrating those will probably break your app. Possible solutions might involve tracking some kind of version or deep-checking state keys. Depending on the outcome you could discard or migrate serialized states.

Lastly, you should consider that refreshing the page is usually the go-to way of resetting an app. So, watch out not to lock your users in a broken state.

For this example we'll develop a simplified solution that saves the whole root state to the localStorage.

Persist with Redux DevTools

โšก Example on StackBlitz

If you just want this feature for development purposes, you don't need to lift a finger: it's already built-in. When you install the Redux DevTools addon in your browser while instrumenting your store with @ngrx/store-devtools you'll be able to persist the state and action history between page reloads.

Here's how this looks in practice:

Persisting NgRx State between page reloads with Redux DevTools

You can't really ask your users to install a browser extension. So, read on if you want to re-hydrate the store to improve not only the developer experience but user experience as well.

Join my mailing list and follow me on Twitter @n_mehlhorn for more in-depth Angular knowledge

Re-Hydration Meta-Reducer

โšก Example on StackBlitz

The popular approach for implementing re-hydration is based on meta-reducers. Such a re-hydration meta-reducer would have to do two things:

  1. Persist the resulting state after each action has been processed by the actual reducer(s)
  2. Provide persisted state upon initialization

Persisting the result state is pretty straight-forward from inside a meta-reducer: we'll serialize the state object to JSON and put it into the localStorage. When you've taken care to keep the state serializable, this should work right-away.

Additionally, NgRx calls reducers once with an undefined state and an INIT action to retrieve the initial state. This would be the place for parsing a potentially existing stored state and returning it instead of the underlying reducer's initial state. Here's how a corresponding meta-reducer might look:



// hydration.reducer.ts
import { ActionReducer, INIT } from "@ngrx/store";
import { RootState } from "..";

export const hydrationMetaReducer = (
  reducer: ActionReducer<RootState>
): ActionReducer<RootState> => {
  return (state, action) => {
    if (action.type === INIT) {
      const storageValue = localStorage.getItem("state");
      if (storageValue) {
        try {
          return JSON.parse(storageValue);
        } catch {
          localStorage.removeItem("state");
        }
      }
    }
    const nextState = reducer(state, action);
    localStorage.setItem("state", JSON.stringify(nextState));
    return nextState;
  };
};


Enter fullscreen mode Exit fullscreen mode

Note that I'm wrapping the parsing into a try-catch block in order to recover when there's invalid data in the storage.

Since we're trying to re-hydrate the whole store, we'll have to register the meta-reducer at the root:



// index.ts
import { MetaReducer } from "@ngrx/store";
import { hydrationMetaReducer } from "./hydration.reducer";

export const metaReducers: MetaReducer[] = [hydrationMetaReducer];


Enter fullscreen mode Exit fullscreen mode


// app.module.ts
import { StoreModule } from '@ngrx/store';
import { reducers, metaReducers } from './store';

@NgModule({
  imports: [
    StoreModule.forRoot(reducers, { metaReducers })
  ]
})


Enter fullscreen mode Exit fullscreen mode

There's a well-known library called ngrx-store-localstorage you might utilize to sync your store to the localStorage. It's leveraging this plain meta-reducer approach and offers some advantages over a custom implementation.

Got stuck? Post a comment below or ping me on Twitter @n_mehlhorn

Re-Hydration Meta-Reducer + Effects

โšก Example on StackBlitz

Serialization, parsing and persistence are processes that clearly sound like side-effects to me. Just because JSON.stringify(), JSON.parse() and the localStorage are synchronous APIs, doesn't mean they're pure. Placing them into a reducer (or meta-reducer) is in itself a violation of NgRx principles. That doesn't mean it's not allowed to implement re-hydration this way, but there might be value in a different approach

Let's rethink re-hydration based on the NgRx building blocks. Interactions with browser APIs should go into effects. However, setting the state is not possible from an effect, so we'll still need a reducer, or rather a meta-reducer. It would only hydrate the state based on an action dispatched by an effect.

We'll start by defining an action that kicks-off the hydration as well as two additional actions that indicate whether a stored state could be retrieved:



// hydration.actions.ts
import { createAction, props } from "@ngrx/store";
import { RootState } from "..";

export const hydrate = createAction("[Hydration] Hydrate");

export const hydrateSuccess = createAction(
  "[Hydration] Hydrate Success",
  props<{ state: RootState }>()
);

export const hydrateFailure = createAction("[Hydration] Hydrate Failure");


Enter fullscreen mode Exit fullscreen mode

Our meta-reducer can be incredibly simple and thus remain pure: it just has to replace the state based on hydrateSuccess actions. In any other case it'll execute the underlying reducer.



// hydration.reducer.ts
import { Action, ActionReducer } from "@ngrx/store";
import * as HydrationActions from "./hydration.actions";
import { RootState } from "..";

function isHydrateSuccess(
  action: Action
): action is ReturnType<typeof HydrationActions.hydrateSuccess> {
  return action.type === HydrationActions.hydrateSuccess.type;
}

export const hydrationMetaReducer = (
  reducer: ActionReducer<RootState>
): ActionReducer<RootState> => {
  return (state, action) => {
    if (isHydrateSuccess(action)) {
      return action.state;
    } else {
      return reducer(state, action);
    }
  };
};


Enter fullscreen mode Exit fullscreen mode

The isHydrateSuccess() helper function implements a user-defined type guard. This way we can safely access the state payload property based on the action type of hydrateSuccess.

Now we can write the effect that dispatches hydrateSuccess and hydrateFailure actions based on whether there's a serialized state available from the localStorage. It'll be started by a hydrate action that we return through the OnInitEffects lifecycle. We'll then try to retrieve a value from the storage using the constant key "state" in order to parse it and return the corresponding hydration actions. If we're successful in parsing the state, it'll end up at our meta-reducer which puts it into the NgRx store.



// hydration.effects.ts
import { Injectable } from "@angular/core";
import { Actions, createEffect, ofType, OnInitEffects } from "@ngrx/effects";
import { Action, Store } from "@ngrx/store";
import { distinctUntilChanged, map, switchMap, tap } from "rxjs/operators";
import { RootState } from "..";
import * as HydrationActions from "./hydration.actions";

@Injectable()
export class HydrationEffects implements OnInitEffects {
  hydrate$ = createEffect(() =>
    this.action$.pipe(
      ofType(HydrationActions.hydrate),
      map(() => {
        const storageValue = localStorage.getItem("state");
        if (storageValue) {
          try {
            const state = JSON.parse(storageValue);
            return HydrationActions.hydrateSuccess({ state });
          } catch {
            localStorage.removeItem("state");
          }
        }
        return HydrationActions.hydrateFailure();
      })
    )
  );

  constructor(private action$: Actions, private store: Store<RootState>) {}

  ngrxOnInitEffects(): Action {
    return HydrationActions.hydrate();
  }
}


Enter fullscreen mode Exit fullscreen mode

What's still missing is an effect that persists the current state to the localStorage in the first place. We'll base it off of the actions stream in order to wait for either an hydrateSuccess or hydrateFailure. This way we won't overwrite an existing state before the re-hydration is done. Then we stop looking at actions an instead subscribe to the store with the switchMap() operator. Slap a distinctUntilChanged() on top and you'll have a stream that emits the state any time it changes. Lastly, we'll mark the effect as non-dispatching and serialize the state to the localStorage inside of a tap() operator.



// hydration.effects.ts
serialize$ = createEffect(
  () =>
    this.action$.pipe(
      ofType(HydrationActions.hydrateSuccess, HydrationActions.hydrateFailure),
      switchMap(() => this.store),
      distinctUntilChanged(),
      tap((state) => localStorage.setItem("state", JSON.stringify(state)))
    ),
  { dispatch: false }
);


Enter fullscreen mode Exit fullscreen mode

Don't forget to register the new effect class in your module declaration. Additionally, you'd be better off injecting the localStorage and/or outsourcing the whole parsing and persistence process into another service.

Apart from complying with the NgRx principles, this effect-based re-hydration implementation additionally allows us to

  • leverage dependency injection and thus ease testing
  • incorporate time-based filtering (e.g. RxJS operators like auditTime())
  • perform advanced error handling
  • re-hydrate from asynchronous sources

The only disadvantage would be that we can't provide a stored state as a direct replacement for the initial state. If that's a requirement, you might try to register reducers via dependency injection in order to still get around an impure implementation.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .