DEV Community

Cover image for The Art of State Management in React.
vorillaz
vorillaz

Posted on • Edited on

The Art of State Management in React.

This is cross-post from my blog tutorial: https://www.vorillaz.com/the-art-of-state-management/.

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"]
Enter fullscreen mode Exit fullscreen mode

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"]
Enter fullscreen mode Exit fullscreen mode

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:

npm install use-context-selector
# or
yarn add use-context-selector
Enter fullscreen mode Exit fullscreen mode

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

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

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;
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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;
  });
};
Enter fullscreen mode Exit fullscreen mode

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]);
};
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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.

Glossary and Links.

Top comments (8)

Collapse
 
lishine profile image
Pavel Ravits

I did try to use the same context lib. I would like build form lib with this. But for app state management I will go back to easy-peasy, it works really well. Mainly because I want all actions in one redux Dev tools column. There is the reinspect.

Collapse
 
vorillaz profile image
vorillaz

I had created some forms with this approach using a form field factory. Basically each field is aware of a slice of the state. You can even follow a conventional approach for error handling. Context can get used with Redux dev tools as well with a bit of tweaking. Since there are no reducers in place you need to add a faux reducer and monitor changes in the state. I have a working example which I am more than happy to share :)

Collapse
 
lishine profile image
Pavel Ravits

Yes, I am very much interested. I have yet to dig into how to connect redux Dev tools to stuff

Thread Thread
 
vorillaz profile image
vorillaz

I’ll come up with a new article and ping you in the upcoming days :)

Thread Thread
 
lishine profile image
Pavel Ravits

Great!

Collapse
 
eddiecooro profile image
Eddie • Edited

Hey Vorillaz, thanks for this great article. I had some doubts regarding using use-context-selector in the production. now I see you benefit from it, I have more courage to test that in my application.
Also, I think there is a small issue in your Provider; you are using useCallback(() => setState(), []) but the setState function returned from the useState hook is guaranteed to not change at all and memoizing it is not useful at all.
Also, check the react-tracked library. it's from the same author as the use-context-selector and has a nice API alongside all of these performance optimizations out of the box.

Collapse
 
vorillaz profile image
vorillaz

Hey Eddie, thanks a lot for the feedback. Looking forward for the results of your effort and thanks a lot for the suggestions I’ll definitely have a look.

Collapse
 
lishine profile image
Pavel Ravits

What I didn't understand, why you wrap the useContextSelector and check for equality when this lib already does that.