How to Handle Unserializable Data with NgRx

n_mehlhorn

Nils Mehlhorn

Posted on January 27, 2021

How to Handle Unserializable Data with NgRx

Contents
What is Serializable and What's Not?
Serializable Replacements
Outsourcing Non-Serializable Data
Conclusion

A fundamental aspect of managing state with NgRx is that all state data needs to be serializable. Runtime state objects are serializable when they can be predictably saved to a persistent storage or transferred over network. In practice, JavaScript objects are mostly serialized to JSON and eventually we'll want our NgRx state to be almost identical to its JSON representation. This way, state can easily be serialized with JSON.stringify() and de-serialized with JSON.parse() without errors or loss of information. Effectively, the result of JSON.parse(JSON.stringify(state)) should be equal to the state itself.

In addition to keeping the state inside the NgRx store serializable, the same considerations also apply for actions and their payloads. Serializability then enables the use of things like the Redux DevTools or persisting NgRx state to the local storage. On top of that, it works well with other functional programming concepts embraced by NgRx like immutability or separation of logic and data.

๐Ÿ“• 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.

NgRx provides certain runtime checks for verifying that your state and actions are serializable. However, per default these aren't turned on and you'll probably only notice problems with serializability once you run into bugs. Therefore it's advisable to activate the corresponding runtime checks for strictStateSerializability and strictActionSerializability - actually it's probably best to activate all available checks while you're at it. This can be done by passing a second configuration parameter to the StoreModule during reducer registration:

@NgModule({
  imports: [
    StoreModule.forRoot(reducers, {
      runtimeChecks: {
        strictStateSerializability: true,
        strictActionSerializability: true,
        /* other checks */
      },
    }),
  ],
})
export class AppModule {}
Enter fullscreen mode Exit fullscreen mode

Now, once you dispatch an action that is not fully serializable, you'll get the following error:

Error: Detected unserializable action at "[path to unserializable property in action]"
Enter fullscreen mode Exit fullscreen mode

If any unserializable data makes it into your state, the error message will look like this:

Error: Detected unserializable state at "[path to unserializable property in state]"
Enter fullscreen mode Exit fullscreen mode

These errors tell us exactly what's wrong, so let's figure out how to fix it.

What is Serializable and What's Not?

First off, here's a list of types that are generally deemed serializable by NgRx and which can therefore be safely stored in the state - note that I'm referring to the JavaScript runtime types:

In contrast, you do not want these types or similar in your state:

While it's not strictly forbidden, you'll also want to avoid classes as their prototype chain can't be restored from JSON. Other than that, classes often tempt you to put functions into the state. Moreover, no classes and/or functions also means that observables shouldn't go into the state.

Serializable Replacements

Some types have good serializable replacements. So you can just use these while maybe accepting little trade-offs here and there.

Map: Object

A Map is almost identical to a regular object - both implement a key-value store. Although they have a different API and there are some subtle differences (e.g. objects only accepts plain keys while maps work with any type of key), it's pretty straightforward to replace maps with regular objects in most cases. You can ensure type safety with index types or leverage TypeScript's Record<Keys, Type>.

Apart from not being serializable, maps are also not immutable. You mutate them by calling methods like set() or delete(). Leveraging objects and the spread syntax is therefore definitely the better choice.

interface Todo {
  id: number
  text: string
  done: boolean
}

interface State {
-  todos: Map<number, Todo>
+  todos: {[id: number]: Todo}
}

const initialState = {
-  todos: new Map<number, User>()
+  todos: {}
}

const reducer = createReducer(initialState,
  on(addTodo, (state, { todo }) => {
-   state.todos.set(todo.id, todo)
+   return {
+      ...state,
+      todos: {...state.todos, [todo.id]: todo}
+   }
  })
);
Enter fullscreen mode Exit fullscreen mode

Set: Array

A Set on the other hand is not identical to a plain array list since sets wonโ€™t accept duplicate entries. You can either prevent duplicates with additional checks or still work with a set but convert back to an array before placing it in the state.

Like maps, sets are also generally not immutable, so there's again two reasons to avoid them.

interface State {
-  selected: Set<number>
+  selected: number[]
}

const initialState = {
-  selected: new Set<number>()
+  selected: []
}

const reducer = createReducer(initialState,
  on(selectTodo, (state, { id }) => {
-   state.selected.add(id)
+   return {
+     ...state,
+     selected: state.selected.includes(id) ? state.selected : [...state.selected, id]
+   }
+   // OR
+   return {
+     ...state,
+     selected: Array.from(new Set([...state.selected, id]))
+   }
  })
);
Enter fullscreen mode Exit fullscreen mode

Date: String or Number

There are two options for serializing a date: either converting it to an ISO string with toJSON() (which calls toISOString() under the hood) or using getTime() to get an epoch timestamp in milliseconds.

While you'll lose the date class functionality in both cases, this isn't really a loss because it's inherently mutable anyway. Meanwhile, the Angular DatePipe directly accepts ISO strings or timestamp numbers. If you still need to transform dates, checkout date-fns for an immutable option.

const scheduleTodo = createAction(
  '[Todo] Schedule',
  props<{
    id: number
-    due: Date
+    due: string
  }>()
)

function schedule(id: number, date: Date) {
  this.store.dispatch(
    scheduleTodo({
      id,
-      due: date,
+      due: date.toJSON(),
    })
  )
}
Enter fullscreen mode Exit fullscreen mode

Class: Object

As I've said, a class's prototype chain will get lost during serialization. However, usually the prototype contains instance methods which don't really fit the picture anyway when we're working with NgRx because that means we're embracing immutability. But we can replace class instances with regular objects and ensure type safety through interfaces or type aliases.

Meanwhile we convert class methods into either reducer logic or external functions depending on what they do. Instance methods which would change the inner state of a class instance should become (immutable) reducer logic because that's where we update state in NgRx. On the other hand, when a class method only exists to derive information, we put it's code into a separate function. Such a function could then be used in a selector to derive a view model.

Heres' an example with before and after:

class Todo {
  private id: string
  private text: string
  private done: boolean

  changeText(text: string): void {
    this.text = text
  }

  getDescription(): string {
    return `[ToDo: ${this.id}]: ${this.text} (${this.done ? 'done' : 'doing'})`
  }
}
Enter fullscreen mode Exit fullscreen mode
interface Todo {
  id: string
  text: string
  done: boolean
}

const reducer = createReducer(
  initialState,
  on(changeText, (state, { id, text }) => {
    const todo = state.todos[id]
    return {
      ...state,
      todos: {
        ...state.todos,
        [id]: {
          ...todo,
          text,
        },
      },
    }
  })
)

function getDescription(todo: Todo): string {
  return `[ToDo: ${todo.id}]: ${todo.text} (${todo.done ? 'done' : 'doing'})`
}
Enter fullscreen mode Exit fullscreen mode

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

Outsourcing Non-Serializable Data

Some types don't really have a direct replacement which would be serializable. In that case we need workarounds in order to keep them out of the store. This part is usually a bit more tricky as solutions are specific to each use-case, but there's always at least one solution.

Function

We already outsourced some functions while replacing classes with regular objects. You can apply the same approach for any other functions you've got floating around and invoke them where necessary. That might be from inside a component, service, selector, effect or similar. The function should be placed according to it's logic. So, something like getDescription() from before could belong next to the model, other operations might be better served as a service method.

Observable

Don't put observables into your store. Instead, let observables interact with your state through actions emitted by effects. Selectors then allow you to pull everything together:

interface Todo {
  id: number
  text: string
  done: boolean
  comments?: string[]
  // don't add something like this
  comments$: Observable<string[]>
}

interface State {
  todos: { [id: number]: Todo }
}

const selectTodo = createSelector(
  (state: State) => state.todos,
  (todos, id: string) => todos[id]
)

const loadComments = createAction(
  '[Todo] Load Comments',
  props<{ id: string }>()
)
const loadCommentsSuccess = createAction(
  '[Todo] Load Comments Success',
  props<{ id: string; comments: string[] }>()
)

const reducer = createReducer(
  initialState,
  on(loadCommentsSuccess, (state, { id, comments }) => {
    const todo = state.todos[id]
    return {
      ...state,
      todos: {
        ...state.todos,
        [id]: {
          ...todo,
          comments,
        },
      },
    }
  })
)

@Injectable()
class CommentEffects {
  comments$ = createEffect(() =>
    this.action$.pipe(
      ofType(loadComments),
      mergeMap(({ id }) =>
        this.http.get<string[]>(`/todos/${id}/comments`)
      ).pipe(map((comments) => loadCommentsSuccess({ id, comments })))
    )
  )

  constructor(private action$: Actions, private http: HttpClient) {}
}

@Component()
class TodoDetailComponent {
  todo$: Observable<Todo>

  constructor(private route: ActivatedRoute, private store: Store) {}

  ngOnInit() {
    this.todo$ = this.route.params.pipe(
      tap(({ id }) => this.store.dispatch(loadComments({ id }))),
      switchMap(({ id }) => this.store.select(selectTodo, id))
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

If you don't want to have additional data in your store or the respective observable is not relevant to the state, you can still outsource it - e.g. into a selection:

interface Todo {
  id: number
  text: string
  done: boolean
  comments?: string[]
}

interface State {
  todos: { [id: number]: Todo }
}

const selectTodo = createSelector(
  (state: State) => state.todos,
  (todos, id: string) => todos[id]
)

@Component()
class TodoDetailComponent {
  todo$: Observable<Todo>

  constructor(
    private route: ActivatedRoute,
    private store: Store,
    private http: HttpClient
  ) {}

  ngOnInit() {
    this.todo$ = this.route.params.pipe(
      switchMap(({ id }) =>
        combineLatest([
          this.store.select(selectTodo, id),
          this.http.get<string[]>(`/todos/${id}/comments`),
        ])
      ),
      map(([todo, comments]) => ({ ...todo, comments }))
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

The same considerations apply for promises.

Special Objects

Special objects like HTML elements or blobs don't have serializable counterparts or serializing (and constantly de-serializing) them would hurt your application performance. However, you might still want to associate these objects with data in you store. In that case you can leverage additional stateful services.

interface Images {
  [id: number]: HTMLImageElement
}

class ImageService {
  private images = new BehaviorSubject<Images>({})

  setImage(id: number, image: HTMLImageElement): void {
    const last = this.images.getValue()
    const next = { ...last, [id]: image }
    this.images.next(next)
  }

  getImage(id: number): Observable<HTMLImageElement> {
    return this.images.pipe(map((images) => images[id]))
  }
}

interface TodoWithImage extends Todo {
  image: HTMLImageElement
}

@Component()
class TodoDetailComponent {
  todo$: Observable<TodoWithImage>

  constructor(
    private route: ActivatedRoute,
    private store: Store,
    private images: ImageService
  ) {}

  ngOnInit() {
    this.todo$ = this.route.params.pipe(
      switchMap(({ id }) =>
        combineLatest([
          this.store.select(selectTodo, id),
          this.images.getImage(id),
        ])
      ),
      map(([todo, image]) => ({ ...todo, image }))
    )
  }
}
Enter fullscreen mode Exit fullscreen mode

You'd have to populate such a service through effects while making sure that any associated resources are cleaned up when the corresponding data is removed from the store.

Conclusion

Serializibility is an important aspect when managing state with NgRx. While it requires us to deviate from certain types, there's a serializable replacement or at least a feasible workaround for every case. If your specific use-case is not covered, drop me a comment and I'll add it.

๐Ÿ“• Get the NgRx book to master all aspects of the Angular state management solution

๐Ÿ’– ๐Ÿ’ช ๐Ÿ™… ๐Ÿšฉ
n_mehlhorn
Nils Mehlhorn

Posted on January 27, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related

ยฉ TheLazy.dev

About