Redux Store patterns (using ES2015 Map)

Toru Kobayashi
10 min readFeb 21, 2019

--

How do you structure Redux Store when your app has relationships between each other?

Let’s consider a Todo app that has states not only todos but also users and memos. The relationship will be like the following.

interface User {
id: number;
name: string;
todos: Todo[];
}
interface Todo {
id: number;
body: string;
memos: Memo[];
}
interface Memo {
id: number;
body: string;
}

This is a nested structure like User -> Todo -> Memo. How do you store the data into Redux Store?

Nested Structure

If you structure your store as well as the above interface, your code will be like the following.

import {
Action,
AllTodos,
RECEIVE_ALL_TODOS,
Memo,
UPDATE_MEMO,
Todo,
ADD_TODO,
createNextTodoId
} from "../app";
import { createStore, Store } from "redux";
interface UserState {
id: number;
name: string;
todos: TodoState[];
}
interface TodoState {
id: number;
body: string;
memos: MemoState[];
}
interface MemoState {
id: number;
body: string;
}
interface State {
users: UserState[];
}
const initialState: State = {
users: []
};
const reducer = (state = initialState, action: Action): State => {
switch (action.type) {
case RECEIVE_ALL_TODOS: {
return action.payload;
}
case UPDATE_MEMO: {
return {
...state,
users: state.users.map(user => ({
...user,
todos: user.todos.map(todo => ({
...todo,
memos: todo.memos.map(memo => {
if (memo.id === action.payload.memo.id) {
return {
...memo,
body: action.payload.memo.body
};
} else {
return memo;
}
})
}))
}))
};
}
case ADD_TODO: {
return {
...state,
users: state.users.map(user => {
if (user.id !== action.payload.userId) {
return user;
}
return {
...user,
todos: user.todos.concat({
id: createNextTodoId(),
body: action.payload.todo,
memos: []
})
};
})
};
}
default:
return state;
}
};
const store: Store<State, Action> = createStore(reducer);const getAllTodos = (state: State): AllTodos => {
return state.users;
};
const getMemoById = (state: State, id: number): Memo | void => {
for (let user of state.users) {
for (let todo of user.todos) {
for (let memo of todo.memos) {
if (memo.id === id) {
return memo;
}
}
}
}
};
const getTodosByUser = (state: State, id: number): Todo[] | void => {
const user = state.users.find(user => user.id === id);
return user ? user.todos : undefined;
};
export { getAllTodos, getMemoById, getTodosByUser, store };

Pros 👍

  • It represents the relationship so developers can understand the code easily
  • Inspecting the Store data using DevTools is very clearly
  • Selectors that return all data will be very simple (getAllTodos and getTodosByUser)

Cons 🤔

  • Update a data in deep level will be very complex because it’s required to treat the data as immutable. See the reducer for UPDATE_MEMO action.
  • Selectors will be also complex if you need to get data at a deep level. See the getMemoById selector.

I think Nested Structure Pattern makes sense if the data isn’t updated after it has been stored in the store and is always accessed through the relationship.

Separated List Structure

This pattern separates each data into separated arrays at a top level. The following is the code that implements the pattern.

import {
Action,
RECEIVE_ALL_TODOS,
UPDATE_MEMO,
AllTodos,
Memo,
Todo,
User,
ADD_TODO,
createNextTodoId
} from "../app";
import { Store, createStore } from "redux";
import flatMap from "array.prototype.flatmap";
interface UserState {
id: number;
name: string;
todoIds: number[];
}
interface TodoState {
id: number;
body: string;
memoIds: number[];
}
interface MemoState {
id: number;
body: string;
}
interface State {
users: UserState[];
todos: TodoState[];
memos: MemoState[];
}
const initialState: State = {
users: [],
todos: [],
memos: []
};
const reducer = (state = initialState, action: Action): State => {
switch (action.type) {
case RECEIVE_ALL_TODOS: {
const users = action.payload.users;
const todos = flatMap(users, user => user.todos);
const memos = flatMap(todos, todo => todo.memos);
return {
users: users.map(user => {
const { todos, ...rest } = user;
return {
...rest,
todoIds: todos.map(todo => todo.id)
};
}),
todos: todos.map(todo => {
const { memos, ...rest } = todo;
return {
...rest,
memoIds: memos.map(memo => memo.id)
};
}),
memos
};
}
case UPDATE_MEMO: {
return {
...state,
memos: state.memos.map(memo => {
if (memo.id === action.payload.memo.id) {
return {
...memo,
body: action.payload.memo.body
};
}
return memo;
})
};
}
case ADD_TODO: {
const todoId = createNextTodoId();
return {
...state,
users: state.users.map(user => {
if (user.id === action.payload.userId) {
return {
...user,
todoIds: user.todoIds.concat(todoId)
};
}
return user;
}),
todos: state.todos.concat({
id: todoId,
body: action.payload.todo,
memoIds: []
})
};
}
default:
return state;
}
};
const store: Store<State, Action> = createStore(reducer);const getAllTodos = (state: State): AllTodos => {
const { users, todos, memos } = state;
return users.map(user => getUser(state, user.id));
};
const getUser = (state: State, id: number): User => {
const user = state.users.find(user => user.id === id)!;
const { todoIds, ...userProps } = user;
return {
...userProps,
todos: todoIds.map(todoId => getTodo(state, todoId))
};
};
const getTodo = (state: State, id: number): Todo => {
const todo = state.todos.find(todo => todo.id === id)!;
const { memoIds, ...todoProps } = todo;
return {
...todoProps,
memos: memoIds.map(memoId => getMemo(state, memoId))
};
};
const getMemo = (state: State, id: number): Memo => {
return state.memos.find(memo => memo.id === id)!;
};
const getTodosByUser = (state: State, id: number): Todo[] | void => {
if (!state.users.some(user => user.id === id)) {
return undefined;
}
return getUser(state, id).todos;
};
export { getAllTodos, getMemo as getMemoById, getTodosByUser, store };

Pros 👍

  • This makes possible to process each data( users, todos and memos) separately so you can create each reducer if you need
  • Inspecting the Store data using DevTools is very clearly

Cons 🤔

  • In order to update data at a deep level, we have to iterate the list and find it to update it. See the reducer for UPDATE_MEMO action
  • Selectors will be complex if the returning data have to represent its relationship. See the getAllTodos selector
  • It will require many operations(find, some etc) to manipulate arrays even though you want to only get a single data into the array

The Separated List Structure might make sense if the data structure is simple and doesn’t need to access specific data by its id.

Normalized Structure

This is a pattern to restructure the data to {[id]: data} style and put the id into a place that the original data was placed.

import {
Action,
RECEIVE_ALL_TODOS,
UPDATE_MEMO,
AllTodos,
Memo,
Todo,
User,
createNextTodoId,
ADD_TODO
} from "../app";
import { Store, createStore } from "redux";
import flatMap from "array.prototype.flatmap";
interface UserState {
id: number;
name: string;
todoIds: number[];
}
interface TodoState {
id: number;
body: string;
memoIds: number[];
}
interface MemoState {
id: number;
body: string;
}
interface State {
userIds: number[];
users: {
[id: number]: UserState;
};
todos: {
[id: number]: TodoState;
};
memos: {
[id: number]: MemoState;
};
}
const initialState: State = {
userIds: [],
users: {},
todos: {},
memos: {}
};
const reducer = (state = initialState, action: Action): State => {
switch (action.type) {
case RECEIVE_ALL_TODOS: {
const users = action.payload.users;
const todos = flatMap(users, user => user.todos);
const memos = flatMap(todos, todo => todo.memos);
return {
userIds: users.map(user => user.id),
users: users.reduce((acc, user) => {
const { todos, ...rest } = user;
return {
...acc,
[user.id]: {
...rest,
todoIds: todos.map(todo => todo.id)
}
};
}, {}),
todos: todos.reduce((acc, todo) => {
const { memos, ...rest } = todo;
return {
...acc,
[todo.id]: {
...rest,
memoIds: memos.map(memo => memo.id)
}
};
}, {}),
memos: memos.reduce((acc, memo) => {
return {
...acc,
[memo.id]: memo
};
}, {})
};
}
case UPDATE_MEMO: {
const { memo } = action.payload;
return {
...state,
memos: {
...state.memos,
[memo.id]: {
...state.memos[memo.id],
body: memo.body
}
}
};
}
case ADD_TODO: {
const todoId = createNextTodoId();
const user = state.users[action.payload.userId];
return {
...state,
users: {
...state.users,
[user.id]: {
...user,
todoIds: user.todoIds.concat(todoId)
}
},
todos: {
...state.todos,
[todoId]: {
id: todoId,
body: action.payload.todo,
memoIds: []
}
}
};
}
default:
return state;
}
};
const store: Store<State, Action> = createStore(reducer);const getAllTodos = (state: State): AllTodos => {
return state.userIds.map(userId => getUser(state, userId));
};
const getUser = (state: State, id: number): User => {
const { todoIds, ...userProps } = state.users[id];
return {
...userProps,
todos: todoIds.map(todoId => getTodo(state, todoId))
};
};
const getTodo = (state: State, id: number): Todo => {
const { memoIds, ...todoProps } = state.todos[id];
return {
...todoProps,
memos: memoIds.map(memoId => getMemo(state, memoId))
};
};
const getMemo = (state: State, id: number): Memo => {
return state.memos[id];
};
const getTodosByUser = (state: State, id: number): Todo[] | void => {
if (!state.users[id]) {
return undefined;
}
return getUser(state, id).todos;
};
export { getAllTodos, getMemo as getMemoById, getTodosByUser, store };

Pros 👍

  • This makes easy to access and update data even the data is at a deep level in the relationship.
  • This makes possible to process each data( users, todos and memos) separately so you can create each reducer if you need
  • You can think that your Store is like a database

Cons 🤔

  • A selector will be complex if the data have to represent its relationship. See the getAllTodos selector
  • You have to use many reduce operations to normalize the original data. See the reducer for RECEIVE_ALL_TODOS action
  • It makes hard to debug data structures using DevTools because a parent data only stores IDs for children. (How do I know what todos that a specific user has?)
  • You need ID arrays for root data(the above case, it is userIds), but it seems to be redundant and you have to maintain ID arrays and normalized data.

I think it is a popular way to refactor your Redux Store to avoid complex reduce operations, which also makes easy to implement getter functions by its ID.

So I recommend using this pattern if you feel that your reducer and selectors are hard to read.

immer provides you a way to implement your reducer easily, you can write update functions with mutable way, which is generally easier than immutable way.

It’s implemented using ES2015 Proxy, which is a bit magical so developers who aren’t familiar with JavaScript are confused by the magic immer provides.

normalizr is a library for this approach. But I didn’t use the library because it brings complexity and it makes hard for developers who aren’t familiar with the library.

Normalized Structure with ES2015 Map

This is a pattern to normalize data with ES2015 Map. ES2015 Map is a useful data structure in JavaScript so I’d like to introduce this to you.

import {
Action,
RECEIVE_ALL_TODOS,
UPDATE_MEMO,
ADD_TODO,
AllTodos,
Memo,
Todo,
User,
createNextTodoId
} from "../app";
import { Store, createStore } from "redux";
import flatMap from "array.prototype.flatmap";
interface UserState {
id: number;
name: string;
todoIds: number[];
}
interface TodoState {
id: number;
body: string;
memoIds: number[];
}
interface MemoState {
id: number;
body: string;
}
interface State {
users: Map<number, UserState>;
todos: Map<number, TodoState>;
memos: Map<number, MemoState>;
}
const initialState: State = {
users: new Map(),
todos: new Map(),
memos: new Map()
};
const reducer = (state = initialState, action: Action): State => {
switch (action.type) {
case RECEIVE_ALL_TODOS: {
const users = action.payload.users;
const todos = flatMap(users, user => user.todos);
const memos = flatMap(todos, todo => todo.memos);
return {
users: new Map(
users.map(user => {
const { todos, ...rest } = user;
return [
user.id,
{
...rest,
todoIds: todos.map(todo => todo.id)
}
] as [number, UserState];
})
),
todos: new Map(
todos.map(todo => {
const { memos, ...rest } = todo;
return [
todo.id,
{
...rest,
memoIds: memos.map(memo => memo.id)
}
] as [number, TodoState];
})
),
memos: new Map(
memos.map(memo => [memo.id, memo] as [number, MemoState])
)
};
}
case UPDATE_MEMO: {
const memo = state.memos.get(action.payload.memo.id);
if (typeof memo === "undefined") {
throw new Error("something went wrong");
}
return {
...state,
memos: new Map(state.memos).set(memo.id, {
...memo,
body: action.payload.memo.body
})
};
}
case ADD_TODO: {
const todoId = createNextTodoId();
const user = state.users.get(action.payload.userId)!;
return {
...state,
users: new Map(state.users).set(user.id, {
...user,
todoIds: user.todoIds.concat(todoId)
}),
todos: new Map(state.todos).set(todoId, {
id: todoId,
body: action.payload.todo,
memoIds: []
})
};
}
default:
return state;
}
};
const store: Store<State, Action> = createStore(reducer);const getAllTodos = (state: State): AllTodos => {
return Array.from(state.users.keys()).map(userId => getUser(state, userId));
};
const getUser = (state: State, id: number): User => {
const { todoIds, ...userProps } = state.users.get(id)!;
return {
...userProps,
todos: todoIds.map(todoId => getTodo(state, todoId))
};
};
const getTodo = (state: State, id: number): Todo => {
const { memoIds, ...todoProps } = state.todos.get(id)!;
return {
...todoProps,
memos: memoIds.map(memoId => getMemo(state, memoId))
};
};
const getMemo = (state: State, id: number): Memo => {
return state.memos.get(id)!;
};
const getTodosByUser = (state: State, id: number): Todo[] | void => {
if (!state.users.has(id)) {
return undefined;
}
return getUser(state, id).todos;
};
export { getAllTodos, getMemo as getMemoById, getTodosByUser, store };

Pros 👍

  • This makes easy to access and update data even the data is at a deep level.
  • This makes possible to process each data( users, todos and memos) separately so you can create each reducer if you need
  • ES2015 Map makes easy to return a sorted list and get data by ID

Cons 🤔

  • A selector will be complex if the data have to represent its relationship. See the getAllTodos selector
  • It makes hard to debug data structures sing DevTools because a parent data only stores IDs for children
  • ES2015 Map is not a serializable data structure so you need extra works if you need to persist your Store data. Redux DevTools doesn’t display data of ES2015 Map by default, so you have to specify serialize option for this.

Caveat for ES2015 Map

ES2015 Map is based on mutable APIs so you have to create a new Map object each time in your reducer (new Map(state.todos)).

ES2015 Map can’t be serialized by JSON.stringify so you have to convert it to like an entities. It is a tuple([key, value]) structure and be able to pass to a constructor of Map directly.

Some Map’s methods like keys(), values() and entities() return an Iterator, not an Array so you have to often use Array.from to convert it to an Array to use filter, map, reduce. But Iterator Helper is a proposal to solve this, which is a Stage1 proposal!!

https://github.com/tc39/proposal-iterator-helpers

ES2015 Map has some caveats but you could make your Store logic easier if your application has complex data structures.

You can see these patterns I’ve introduced.

Which approaches do you use?

--

--