The Art of State Management in React.

The Art of State Management in React.
Image Credits: https://unsplash.com/@williamdaigneault

Over the past few years, I’ve had the privilege (or perhaps the curse) of implementing various state management solutions recommended by the React community in production environments. From Flux and Redux to prop drilling and the Context API, I can totally brag,I’ve seen it all.

Crafting a scalable and efficient state management architecture, particularly for applications with extensive stores, can be challenging. In this guide, I’ll walk you through using React Context alongside hooks effectively. We’ll create a straightforward Todo application, available on CodeSandbox and GitHub.

Key Principles

To ensure our application is both performant and scalable, we’ll adhere to these principles:

  1. Transparency: Maintain control over state changes without side effects.
  2. Component-Centric: Components are responsible for consuming and updating state within their lifecycle.
  3. Minimal Rerenders: Components should only rerender when their specific slice of state changes.
  4. Code Reusability: Easily create and integrate new components with minimal boilerplate.

Understanding Selectors

Selectors are pure functions that compute derived data, inspired by the `reselect` library often used with Redux. They can be chained to manipulate or retrieve parts of the state.

Consider this simple example where our state stores a list of todo tasks:

const state = ['todo1', 'todo2'];

const getTodos = todos => todos;
const getFirstTodo = todos => todos[0];
const addTodo = todo => todos => [...todos, todo];

getFirstTodo(getTodos(state)); // => 'todo1'
addTodo('todo3')(getTodos(state)); // => ["todo1", "todo2", "todo3"]

To improve readability when chaining selectors, we can use a wrapper function:

const noop = _ => _;

const composeSelectors = (...fns) => (state = {}) =>
  fns.reduce((prev, curr = noop) => curr(prev), state);

composeSelectors(getTodos, getFirstTodo)(state); // => 'todo1'
composeSelectors(getTodos, addTodo('todo3'))(state); // => ["todo1", "todo2", "todo3"]

Libraries like Ramda, lodash/fp, and Reselect offer additional utility functions for use with selectors. This approach allows for easy unit testing and composition of reusable code snippets without coupling business logic to state shape.

Integrating Selectors with React Hooks

Selectors are commonly used with React hooks for performance optimization or as part of a framework. For instance, `react-redux` provides a `useSelector` hook to retrieve slices of the application state.

To optimize performance, we need to implement caching (memoization) when using selectors with hooks. React’s built-in `useMemo` and `useCallback` hooks can help reduce the cost of state shape changes, ensuring components rerender only when their consumed state slice changes.

Context Selectors

While selectors are often associated with Redux, they can also be used with the Context API. There’s an RFC proposing this integration, and an npm package called `use-context-selector` that we’ll use in this guide. These solutions are lightweight and won’t significantly impact bundle size.

Setting Up the Provider

First, install `use-context-selector`:

Create a Context object with a default value in `context.js`:

import {createContext} from 'use-context-selector';
export default createContext(null);

Next, create the `TodoProvider` in `provider.js`:

import React, {useState, useCallback} from 'react';
import TodosContext from './context';

const TodoProvider = ({children}) => {
  const [state, setState] = useState(['todo1', 'todo2']);
  const update = useCallback(setState, []);
  return <TodosContext.Provider value={[state, update]}>{children}</TodosContext.Provider>;
};
export default TodoProvider;

Implementing the Main Application

Wrap your application with the `TodosProvider`:

import React from 'react';
import TodosProvider from './provider';
import TodoList from './list';

export default function App() {
  return (
    <TodosProvider>
      <TodoList />
    </TodosProvider>
  );
}

Creating the Todo List Component

The main component renders a bullet list of todo items and includes a button to add new items:

import React, {useCallback} from 'react';
import Ctx from './context';
import {useContextSelector} from 'use-context-selector';

export default () => {
  const todos = useContextSelector(Ctx, ([todos]) => todos);
  const update = useContextSelector(Ctx, ([, update]) => update);
  const append = todo => update(state => [...state, todo]);

  const add = useCallback(e => {
    e.preventDefault();
    append('New item');
  }, [append]);

  return (
    <div>
      <ul>
        {todos.map(todo => (
          <li key={todo}>{todo}</li>
        ))}
      </ul>
      <button onClick={add}>Add</button>
    </div>
  );
};

Enhancing Selectors

We can use the `composeSelectors` helper to leverage the power of composition:

const getState = ([state]) => state;
const getUpdate = ([, update]) => update;

const todos = useContextSelector(Ctx, composeSelectors(getState));
const update = useContextSelector(Ctx, composeSelectors(getUpdate));

Optimizing the useContextSelector Hook

For an extra performance boost, implement a wrapper around the original `useContextSelector` hook:

import {useRef} from 'react';
import identity from 'lodash/identity';
import isEqual from 'lodash/isEqual';
import {useContextSelector} from 'use-context-selector';

export default (Context, select = identity) => {
  const prevRef = useRef();
  return useContextSelector(Context, state => {
    const selected = select(state);
    if (!isEqual(prevRef.current, selected)) prevRef.current = selected;
    return prevRef.current;
  });
};

This implementation uses `useRef` and `isEqual` to check for state updates and force updates to the memoized composed selector when necessary.

Creating Memoized Selectors

Add an extra memoization layer for selectors using the `useCallback` hook:

const useWithTodos = (Context = Ctx) => {
  const todosSelector = useCallback(composeSelectors(getState), []);
  return useContextSelector(Context, todosSelector);
};

const useWithAddTodo = (Context = Ctx) => {
  const addTodoSelector = useCallback(composeSelectors(getUpdate), []);
  const update = useContextSelector(Context, addTodoSelector);
  return todo => update(todos => [...todos, todo]);
};

Testing

Testing becomes straightforward with this approach. Use the `@testing-library/react-hooks` package to test hooks independently:

import {renderHook} from '@testing-library/react-hooks';
import {createContext} from 'use-context-selector';
import {useWithTodos} from './todos';

const initialState = ['todo1', 'todo2'];

it('useWithTodos', () => {
  const Ctx = createContext([initialState]);
  const {result} = renderHook(() => useWithTodos(Ctx));
  expect(result.current).toMatchSnapshot();
});

Handling Async Actions

To integrate with backend services, pass a centralized async updater through the `TodoProvider`:

const TodoProvider = ({children}) => {
  const [state, setState] = useState(['todo1', 'todo2']);
  const update = useCallback(setState, []);
  const serverUpdate = useCallback(
    payload => {
      fetch('/api/todos', {
        method: 'POST',
        body: JSON.stringify(payload)
      }).then(data => {
        // Optionally update the state here
        // update(state => [...state, data])
      });
    },
    [update]
  );
  return (
    <TodosContext.Provider value={[state, update, serverUpdate]}>{children}</TodosContext.Provider>
  );
};

Advanced Techniques

In rare cases, you might need to combine data from multiple providers. While this approach is generally discouraged due to potential performance issues, here’s how it can be implemented:

export const useMultipleCtxSelector = ([...Contexts], selector) => {
  const parseCtxs = useCallback(
    () => Contexts.reduce((prev, curr) => [...prev, useContextSelector(curr)], []),
    [Contexts]
  );
  return useContextSelector(createContext(parseCtxs()), selector);
};

Note that this technique violates the hooks concept by using `useContextSelector` inside a loop.

Conclusion

While these techniques may seem complex, especially compared to Redux, they offer significant benefits for production-grade projects where state management grows over time. Selectors allow for isolation, composition, and minimal boilerplate code, making components aware of state changes efficiently.

This approach can be particularly effective for creating large forms with controlled inputs without side effects. The main principles can be summarized as:

  1. Actions are only triggered through components.
  2. Only selectors can retrieve or update the state.
  3. Composed selectors are always hooks.

While this method lacks some features like time traveling and labeled actions, it provides a solid foundation for state management. It can save time, effort, and boost productivity and performance in your React applications.

You can find the complete demo application on CodeSandbox and GitHub.

Thank you for your time and attention.