From time to time someone still tells me that is using REDUX or similar tool in their project. I usually respond, that I wouldn't use it as now with hooks and context API you don't need it.
But context API usually brings performance problems and is also a bit awkward to use it properly, so today I'll try to show how to avoid common problems, and also build your own (micro) state management tool, without any compromises.
Naive solution
Basic idea is to manage state in one component and pass the whole it by context so it's accessible from all child components, so we can avoid props drilling.
export const StateContext = createContext(null);
const Provider = () => {
return (
<StateContext.Provider value={state}>
<ChildComponent />
</StateContext.Provider>
)
}
Using dispatch
However you also need some way how to modify the state from children, you could pass individual functions to the context, but I personally don't like that as the state will get complex very fast. I like idea of dispatching events (similarly as in REDUX), so we basically pass one function which you can use to dispatch all different actions that you need. We could pass it through the same context as the state, but I don't like mixing it with the state, so I pass it through a separate context.
const StateContext = createContext(null);
const DispatchContext = createContext(null);
export const Provider = () => {
const [state, setState] = useState(...)
const dispatch = (action) => {
switch (action.type) {
case 'CHANGE_STATE':
setState(action.payload)
break;
...
}
}
return (
<StateContext.Provider value={{state, ...}}>
<DispatchContext.Provider value={dispatch}>
<ChildComponent />
</DispatchContext.Provider>
</StateContext.Provider>
)
}
I also like creating hook for getting the dispatch function to make it more explicit:
export const useDispatch = () => {
return useContext(DispatchContext)
}
Basically we are separating data from actions - provider component provides data to children. Children can dispatch actions to modify the data, but it's controlled by provider component, so it has control over it. Dispatched actions can be understood similarly as e.g. dom events, except we know who will receive it.
Now let's look at the performance side as if we want to use this as a replacement of REDUX, it needs to be able to handle big states with a lot of components subscribed.
Avoiding unnecessary children re-creation
In this configuration we are really inefficient, as all the children will get re-rendered every time we change something in the state. This happens because every time we update state in Provider component, all it's children will get re-created. We could use React.memo on children to avoid this, however nicer solution is to pass children from component above, so when the Provider is updated, children will stay the same. And we only update actual context consumers.
export const Provider = ({ children }) => {
...
return (
<StateContext.Provider value={{state, ...}}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
)
}
In parent we do:
export const Parent = ({ children }) => {
return (
<Provider>
<ChildComponent />
</Provider>
)
}
Now the provider component is managing the context, but is not managing children (only passing them). It took me a while to understand this subtle difference as it is quite small change in the code, with big consequences.
The trick is to understand, that when we put <ChildComponent >
, we are basically creating new React.Node every time, so all the children are re-rendered, unless we wrap them in React.memo
.
So with this change, we update only components which are using the context.
Avoiding dispatch causing re-renders
Currently dispatch function is re-created every time the state is changed, which mean that all components using it will get re-rended, even though they are not using StateContext. Usually if we want to have stable function react documentation advices to use useCallback
, but in this case it will help us only partially, because, that will basically cause "caching" of dispatch function and we wouldn't be able to use outer scope variables without including them into dependencies
- and then the dispatch function would still get recreated when dependencies change. We will need to use ref
to help us with this.
...
export const Provider = ({ children }) => {
const [state, setState] = useState(...)
const dispatchRef = useRef()
// new function with every render
const dispatchRef.current = (action) => {
switch (action.type) {
case 'CHANGE_STATE':
// we can use outer scope without restrictions
setState({...action.payload, ...state})
break;
...
}
}
// stable dispatch function
const dispatch = useCallback(
(action: ActionType) => dispatchRef.current(action),
[dispatchRef]
);
return (
<StateContext.Provider value={{state, ...}}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
)
}
This way stable dispatch function is passed to the DispatchContext
and we can use outer scope without limitations.
Subscribable context
Last optimization we'll need is ability of the component subscribe only to part of the state. Now components can only use whole state and even when they need just small piece (e.g. one boolean value), they'll get notified every we change the state. This is not the best practice as we would still get unnecessary re-renders. The way to solve this is through use-context-selector.
This library is quite simple and it allows to use selector function, to "pick" what we want from the state.
import { createContext } from 'use-context-selector';
const StateContext = createContext(null);
export const Provider = ({ children }) => {
return (
<StateContext.Provider value={{state, ...}}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
)
}
import { useContextSelector } from 'use-context-selector';
export const Subscriber = () => {
const somePart = useContextSelector(StateContext, context => context.somePart)
}
Oh, wait that is cheating! You said you will only use Context API!
This library is quite simple wrapper of React.Context api. It uses ref
to wrap passed value, so that components are not re-rendering automatically and then it keeps list of subscribers. When value changes it runs all the subscribed functions and if the value from the selector is different than before it forces the subscribed Component to re-render. Similar concept is used e.g. in redux useSelector hook. So I say, it's quite standard solution and why build a new one, when it already exists?
PS: there is even a open RFC to add something like this directly into react
Final product
We can wrap this whole functionality to be reusable (+ add typescript types)
import React, { useCallback, useRef } from 'react';
import { createContext, useContextSelector } from 'use-context-selector';
type DispatchType<ActionType, DispatchReturn> = (
action: ActionType
) => DispatchReturn;
type SelectorType<StateType> = (state: StateType) => any;
export const createProvider = <
StateType,
ActionType,
DispatchReturn,
ProviderProps
>(
body: (
props: ProviderProps
) => [state: StateType, dispatch: DispatchType<ActionType, DispatchReturn>]
) => {
const StateContext = createContext<StateType>(null as any);
const DispatchContext = React.createContext<
DispatchType<ActionType, DispatchReturn>
>(null as any);
const Provider: React.FC<ProviderProps> = ({ children, ...props }) => {
const [state, _dispatch] = body(props as any);
const dispatchRef = useRef(_dispatch);
dispatchRef.current = _dispatch;
// stable dispatch function
const dispatch = useCallback(
(action: ActionType) => dispatchRef.current?.(action),
[dispatchRef]
);
return (
<StateContext.Provider value={state}>
<DispatchContext.Provider value={dispatch}>
{children}
</DispatchContext.Provider>
</StateContext.Provider>
);
};
const useDispatch = () => React.useContext(DispatchContext);
const useStateContext = (selector: SelectorType<StateType>) =>
useContextSelector(StateContext, selector);
return [Provider, useDispatch, useStateContext] as const;
};
Usage example
type ActionType =
| { type: 'CHANGE_STATE'; payload: ... }
...
export const [
TranslationsContextProvider,
useTranslationsDispatch,
useTranslationsSelector,
] = createProvider(
(props /* provider props */) => {
const [state1, setState1] = useState(...)
const [state2, setState2] = useState(...)
const {data, isLoading} = useQuery(...)
const dispatch = (action: ActionType) => {
switch (action.type) {
case 'CHANGE_STATE':
setState(action.payload)
break;
...
}
}
const state = {
state1,
state2,
data,
isLoading
}
// don't forget to return state and dispatch function
return [state, dispatch]
})
Lets summarize advantages of this solution:
- Simple usage, nothing new to learn no boilerplate as with REDUX etc.
- More efficient than Context api used naively
- It scales as you have the whole power of hooks
- You can use many instances and scope them only to the part of app that need them
In Tolgee.io, we use this on our most complicated view, where we handle translations table and we didn't have any problems with it yet.
What do you think?
PS: Check Tolgee.io and give us github stars
Latest comments (69)
Thanks for sharing a great article, I am also using a similar approach but I haven't found anything around devtool for such an approach. I'm curious are you using anything for debugging this kind of similar to redux devtool? Also, what do you think of github.com/nanostores/nanostores?
I believe if anyone tries Vuex (redux equivalent of Vue) will definitely understand what's wrong with redux.
Vuex logic is far more simple , practical and easy to learn .
It actually makes it fun to use .
The problem with React Context is that (due to the nature of React itself) it behaves like a state management tool, while in reality it is a tool for dependency injection. This confusion often leads to the idea to use React Context as a state manager, which it was never intended for.
If you take a closer look at any decent state management solution, you will quickly notice that these tools never pass "naked" state (values that change frequently) directly via context, they instead wrap the state with "something" and then pass this "something" through the context. This "something" is generally called "an observable store".
Different tools use different names ("store" - redux, effector, zustand, etc.; "client (cache)" - apollo-client; "query client (cache)" - react-query), but the end result is the same - "naked" state is wrapped and the referential equality is preserved even when the state changes.
So, instead of using cryptic libraries like
use-context-selector
, it might be a better idea to take advantage of the "observable store" pattern. If you don't feel like using an existing solution, you can easily implement everything yourself, it's like ~30 LOC with all the infrastructure.Just use mobx and observer. You can combine observables with hooks and the observer component wrapper for FC development and have clean state management. Use the container -> presentation pattern.. make each route in the app a container, use react router V6 object routing to pass observable stores to your containers and prop drill to presentation components so things are easier to test.
Keep presentation components dumb, containers complex.
Use outlets and contexts to handle layout.
I.e create a page that uses contexts to let children update it, do you can have
<Page...
...child route in outlet...
The parent route where page is uses an observer/observable, Content uses context to access the pages mobx observable so it can add content to the page.
Mobx memoizes components automatically for you.
I like it way better than redux.
Optionally you can use custom hooks to grab contexts for parent observable states. So you could make a hook called "usePageContent" and give it append, replace, etc methods.
this aproach will have problems when hot module enabled. I have also developed this kind of lib for Form and FormElements. When you build form there will be no problem but when you change the body (main sate) and hot module updates it the Provider context will lost its value and recreate it from scratch since Form and form element is attached to previous context the render will fail. (using CreateContext from React)
I like your post style as it’s unique from the others I’m seeing on the page. Skatter Crack
You probably don’t need useRef for dispatch if you use the function version of setState:
setState(old => ({…old, key:val}))
🤔What do you think about Teaful 😊?
github.com/teafuljs/teaful
I think it's even less boileplate, and you still avoid more rerenders, since it only rerenders if the store property being consumed is updated, and not others.
Redux is not only context provider. It is reliable and easily debugable state structure for your application.
Sure, it is complex and has a lot of boilerplate code even with RTK. But if it is too complex for your app, it means only that your app is not big enough to profit from Redux. Use native state management or simpler solutions like MobX.
So I take it that you never used Redux toolkit and also never had to test the data in your global store in one big project?!
I've used REDUX about 2 years ago and now I'm working on project where the redux is used, but basically have it's there as a legacy and are trying to get rid of it. So I haven't used it yet. And store based testing? No, honestly I've never had a use case for it. We either were writing unit tests for small components or using cypress for testing the app as a whole.
Setting a bunch of hooks and effects in React components really shows what a big mess React is, compared to more elegant solutions with Angular and Vue.
Typed Observable Reactive stores is where it's at and the APIs for using those are much more composable and beautiful. I can't believe React has become such a mess, but I guess that's why it's the oldest of the big frameworks.
I'm crying in
zustand
. It's much simpler to setup & doesn't impact the bundle size much. Now everyone (REDUX FANBOIS) will say redux-toolkit makes setting up redux easier but that's what redux itself should do. Why would I add another library over already installed redux & react-redux summing up my total bundle size with another 40KB?So I now agreed on a combo of ZUSTAND & React-Query which is highly recommended for others too (IMO)
Actually you dont need React, you can use regular html and js
I understand Redux has quite a bit of boiler plate but honestly, I hate Hooks so much and wish it never came out. Too many devs are writing sloppy code with them and large projects are getting extremely hard to manage and debug simply because there is no standardization of how to structure custom hooks. Never using Hooks and will continue to use Redux. Only exception is a quick component that needs useState but that's about it.
I'll stick with Redux. Context Api increases complexity
Some comments may only be visible to logged-in visitors. Sign in to view all comments.