State management is hard
State management is hard get right in complex React apps for most of us. State can include UI state like routes, form states, pagination, selected tabs, etc as well as the response from http calls, loading states, cached data etc.
Even at Facebook, they had difficulty in showing the correct notification count for chat messages.
The necessity to tame this increasing complexity gave rise to some interesting libraries and paradigms.
Some of the popular state-management libraries out there:
Redux might be the single most popular library used in tandem with React. It popularized the notion of uni-directional flow of data and made state updates predictable and manageable.
We’ll try to build a utility with the same principles in mind, a single source of truth with uni-directional flow of data where state updates are performed by dispatching an action (pure functions).
Context API
Context provides a way to pass data through the component tree without having to pass props down manually at every level.
Context is a powerful tool to have. In fact,
Redux binding for React
itself uses the Context
API. Along with the useReducer
& useContext
hooks we have all the
pieces to build our state management utility.
Demo time
We’ll be building a basic counter with 2 buttons to increment and decrement the count. Our global
store will have a single piece of state called count
. The demo will be using Typescript.
Building the global store and the reducer
First lets create the context object. It will have two properties the state object itself and the dispatch function.
// ...
const GlobalStateContext = createContext<{
state: State;
dispatch: (action: Action) => void;
}>({ state: INITIAL_STATE, dispatch: () => {} });
// ...
When React renders a component that subscribes to this Context object it will read the current context value from the closest matching Provider above it in the tree.
The reducer function is fairly the same as a Redux reducer, which performs state updates on incoming Action and then returning the new state.
Putting it all together.
import { createContext, Reducer } from 'react';
import { ActionTypes } from './globalActions';
interface State {
count: number;
}
export const INITIAL_STATE: State = {
count: 0
};
export interface Action {
type: ActionTypes;
payload?: any;
}
export const GlobalStateContext = createContext<{
state: State;
dispatch: (action: Action) => void;
}>({ state: INITIAL_STATE, dispatch: () => {} });
export const globalReducer: Reducer<State, Action> = (state, action) => {
const { type } = action;
switch (type) {
case ActionTypes.INCREMENT:
return { ...state, count: state.count + 1 };
case ActionTypes.DECREMENT:
return { ...state, count: state.count - 1 };
default:
return state;
}
};
We have 2 actions INCREMENT
& DECREMENT
and corresponding action creators which dispatches those
actions.
export enum ActionTypes {
INCREMENT = 'INCREMENT',
DECREMENT = 'DECREMENT'
}
export const incrementAction = () => ({
type: ActionTypes.INCREMENT
});
export const decrementAction = () => ({
type: ActionTypes.DECREMENT
});
Connecting the store to the components
Every Context object comes with a Provider
React component that allows consuming components to
subscribe to context changes. It receives a prop value
consuming components that are descendants
of this Provider.
useReducer
is a hook that accepts the reducer and the initial state and returns the current
state paired with a dispatch method. (If you’re familiar with Redux, you already know how this
works.)
We need to wrap the root component of our app in the Provider
, and pass the returned state and
dispatch as the value
prop.
// ...
const [globalState, dispatchToGlobal] = React.useReducer(
globalReducer,
INITIAL_STATE
);
return (
<GlobalStateContext.Provider
value={{ state: globalState, dispatch: dispatchToGlobal }}
>
<div className='App'>
<Layout />
</div>
</GlobalStateContext.Provider>
);
// ...
At this point, our entire app has access to the global state and can dispatch actions to the store. Now lets connect the UI components to the store.
The useContext
hook accepts a Context object and returns the current context value for that
context, which in our case is the state
& dispatch
method.
import * as React from 'react';
import { GlobalStateContext } from './context/globalStore';
import { incrementAction, decrementAction } from './context/globalActions';
const Layout: React.FC = () => {
const { state, dispatch } = React.useContext(GlobalStateContext);
return (
<div>
<div>
<h2>Count : {state.count}</h2>
</div>
<div>
<button onClick={() => dispatch(incrementAction())}>Increment</button>
<button onClick={() => dispatch(decrementAction())}>Decrement</button>
</div>
</div>
);
};
export default Layout;
Performance
This approach is suited for low frequency state updates. React Redux uses context internally but only
to pass the Redux store instance down to child components - it doesn’t pass the store state using
context. It uses store.subscribe()
to be notified of state updates.
Passing down the store state will cause all the descendant nodes to re-render.
See more about this here
- https://blog.isquaredsoftware.com/2020/01/blogged-answers-react-redux-and-context-behavior/
- https://github.com/facebook/react/issues/15156#issuecomment-474590693
Souce code
Checkout the full source at CodeSandbox
Conclusion
The state management utility we created here shows what’s possible with React Hooks & Context API. This approach as it is, without any performance optimizations, is best suited for low frequency state updates like theme, localization, auth, etc. For high frequency updates I still use Redux and you should try it too.