vorillazathens · 2026.10
writing20 articles

The Art of State Management in React

Mastering React State Management: A Guide to Context API and Hooks.

contents15 sections

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:

javascript
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:

javascript
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:

shell
npm install use-context-selector

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

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

Next, create the TodoProvider in provider.js:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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:

javascript
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.