Taming Redux state at scale

Code Pray Deploy

Everyone familiar with the Redux state management library is probably also familiar with its basic example of a todo list application.
While this example illustrates some of the strengths and patterns associated with Redux quite nicely, it’s a bit overwhelming for such a simple use-case. It makes you wonder… how does Redux scale for big applications with thousands of actions used by dozens of modules? How do you ensure your work can be leveraged by other developers and teams working in the same domain? Most importantly, how will you keep everything well-structured and organized in order to develop, extend, and reshape your applications long-term?

Identify entities

At Mews, we start by describing the business logic of a new function or application module. Every noun in that description has the potential to become a separate entity: a self-contained piece of code that will appear throughout the application. In the simple todo application mentioned above, todos is a great example of this. We split such entities to state separated from modules using them.

state = {
modules: {
todoList: {
selectedTodoId: 'id1',
}
},
entities: {
todos: {
id1: {...},
id2: {...},
}
}
}

This way, state becomes flatter. It’s easier to investigate application state and selector routes are nice and short. Moreover, we’re forced to properly expose data selectors and actions, making them easily accessible to other parts of the application.
Notice that todos is keyed object now, allowing efficient access to specific objects. Since the state of todos is generic, and usable by multiple modules, data needn’t be stored in any particular order (which differs with every usage).

Mind the API

The creation of simple, self-contained files that describe specific entity handling follows naturally from extracting entities. With the inclusion of reducers, selectors, and actions that can be taken on an entity, we arrive at the well-known duck pattern. While not a new idea, it can be truly transformative when fully-adopted.

// entities/todos.js
// actions
export const fetchTodos = ...
export const updateTodo = ...
export const deleteTodo = ...

// reducer
export default todosReducer = ...

// selectors
export const getTodosByIds = ...
export const getTodoById = ...

Our duck files can now handle the entire life cycle of an entity, from the moment the application receives server data to its deletion. This works very well if your API layer adheres strictly to RESTful principles (especially that of uniform interface) when it comes to entities having their own specific API endpoints and easy-to-define operations. That’s not the case with our example.
Lets say our todos have owners and thus a unique ownerId provided by the server. If our API were truly RESTful, we would have to fetch todos, parse them, then fetch owners based on ids found in the todos API responses. It’s clear that this would increase latency. Since the server can retrieve owners faster than it takes to do a second API call, it’s preferable to provide todos and owners in single call. Our API responses will end up with mix of entities in each call, no matter what technique we use.

// fetch todos expand owner
const response = [{
id: '1',
text: 'buy milk',
ownerId: '33',
owner: { name: 'Jack', id: '33' }
}]
// fetch todos extend owner
const response = {
todos: [{
id: '1',
text: 'buy milk',
ownerId: '33'
}],
owners: [{
name: 'Jack',
id: '33'
}]
}

And that forces our next design decision. Ducks have to be separated from the API layer, since multiple entities can be provided by one API call and multiple API calls can provide one entity. Keeping the API layer in ducks would lead to cross-duck dependencies, messy code, and unclear data flow paths. Instead, let’s create an additional layer of abstraction for the API endpoints. Such a layer will handle the API connection and use duck actions for loading entities’ form results to respective parts of application state.

//api/endpoints/todos.js
import { addTodos } from './entities/todos';
import { addOwners } from './entities/owners';

fetch(....).then(({ owners, todos }) => {
dispatch(addTodos(todos));
dispatch(addOwners(owners));
})

Generate CRUD

Luckily, we can use this to our advantage. Our ducks are essentially free of any specific code. They usually consist of a simple reducer with add, update, and delete operations, as well as a set of generic selectors (getAll, getByIds) and simple actions like add, update, and delete. Such operations not only create the whole CRUD, but are also easily-defined without knowledge of specific entities. Now we can finally stop writing our ducks, generating them, instead, with just a few lines of factory functions.

// entities/todos.js
const TODO = 'entities/TODO';
const [TODO_ADDED, TODO_UPDATED, TODO_DELETED] = createBasicActionTypes(TODO);

// actions using action types
export default createEntityReducer({
added: TODO_ADDED,
updated: TODO_UPDATED,
deleted: TODO_DELETED,
});

export const [getAllTodos, getTodoById] = createBasicSelectors(parentSelector)

// any specific selectors for this entity

Get your ducks in a row

                _      _      _
>(.)__ <(.)__ =(.)__
(___/ (___/ (___/

When looking at the pseudocode above, you might have noticed that ducks are only dependant on two parameters. The initial TODO action and parentSelector, both used to scope a duck with unique action names and reducer placement within application state. It’s now clear how to make our duck files shareable with other devs and teams: simply allow duck configuration with those two parameters, so that functionality can be plugged into different states regardless of shape.
Now we’ve arrived to well-separated state management that’s easily extensible, yet stripped to its most basic components and, as such, that can be created and modified efficiently and with reusability. Our ducks are now agnostic to data sources and can be filled by a multitude of sources, including localStorage and pre-rendered data from a server, using the same functions.
Photo © Natalia Bubochkina.

Share:

More About