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:
- Transparency: Maintain control over state changes without side effects.
- Component-Centric: Components are responsible for consuming and updating state within their lifecycle.
- Minimal Rerenders: Components should only rerender when their specific slice of state changes.
- 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
:
npm install use-context-selector
# or
yarn add 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:
- Actions are only triggered through components.
- Only selectors can retrieve or update the state.
- 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.
- react-redux
- Context selectors RFC
- Performance optimization in React docs
- @testing-library/react-hooks
- Time travelling in Redux
- useState in React docs
- useRef in React docs
- shouldComponentUpdate in React docs
- useMemo in React docs
- useCallback in React docs
- Ramda
- Hooks API in React docs
- TodoMVC
- lodash/fp
- Redux
- Pure functions definition
- Reselect
Top comments (8)
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.
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 :)
Yes, I am very much interested. I have yet to dig into how to connect redux Dev tools to stuff
I’ll come up with a new article and ping you in the upcoming days :)
Great!
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 theuse-context-selector
and has a nice API alongside all of these performance optimizations out of the box.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.
What I didn't understand, why you wrap the useContextSelector and check for equality when this lib already does that.