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
Top comments (69)
My whole reasoning for not using Redux is because Redux is overly complex for what it needs to be. While your solution works well for people that like Redux, you've essentially given no benefit to the developer for picking Context over something like Redux Toolkit, because it does a lot of these things behind the scenes for you already. Instead of setting up a specific way to use Context, you could just load in Redux Toolkit and be done with it - you mentioned this is less boilerplate but I'm not sure I see that?
Maybe it's a bit confusing, that I'm using
dispatch
function. But I only named it this way as a convention, this differs completely from REDUX, I don't use anything like reducers, action creators, store etc. - that's what I mean by reducing boiler plate. I took dispatch as a way to send message to the Provider to make some mutation to the state, thats all.But you literally just used implemented the Redux reducer pattern (
useReducer hook, my mistake) which accepts a reducer function, and returns a dispatcher that accepts actions that will be created by action creators as the project scales. You didn't reduce boilerplate, you just re-created the entire Redux logic without the store setup.Ummm... you just described how Redux works 😅 except you are now forced to write action creators manually to avoid typo issues, when Redux Toolkit could be generating all the action creators for the reducer for you, see:
I don't mean to be disrespectful, but I think you don't understand the article. I haven't used
useReducer
anywhere and I didn't even duplicate it's logic (if that's what you mean). Also I didn't recreated whole redux logic (that would be quite a challenge :D). I don't think that writing action creators is necessary - if you use typescript it should prevent typos, I've suggested that in the article. All in all, I'm glad that you are interested into this topic, but it feels like you are arguing against things that I haven't wrote in the article.My apologies, you are absolutely right! I missunderstood you reducer implementation using
useRef
withuseReducer
given it looked so much like one. However, you did recreate the reducer and action creator part of it, which were still my original points. See your usage example where you literally wrote something extremely similar to a Redux Reducer and action dispatching:And there's your problem. You are making the assumption everyone uses typescript now. And it's yet another reason why people build action creators... because they just work at ensuring consistent actions types, independent of if you're using typescript or not.
You opened your article with "React doesn't need state management tool {...} 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.". That's a pretty big and powerful statement you opened your article with which implies you have an objectively better state management solution than redux in all scenarios. And not just that, but you only listed advantages to your solution, with zero disadvantages.
Just as a heads up, but if you want to make bold claims like that, you need to be prepared to back them up 🙂 And not be surprised when people start pointing out the flaws in your proposed alternative. If you had worded your article's title to "Building your own redux-like state management solution", you can be sure you wouldn't have gotten half as constructive feedback as this 😉 Since at no point would you claim it's a better solution than Redux, just a different one that might be useful for some people. Still, I don't blame you. Hyperbole-like, clickbait-y titles are usually what drives clicks 🤷♂️ Can't hate the player, hate the game.
Yep, I opened the article with a bit of provocation, that's true. I also made sure, it's clear that it's only my personal opinion. But I truly think that this way of managing the state can remove the need of REDUX. I don't say, that it's just this little piece of code that I wrote, but basically instead of using REDUX for everything (state, API calls, forms ...), we use hook libraries like react-query, formik etc. I'm sure people are doing this all the time. However sometimes there is a need for a bit of complex state management and that is what we use this solution for - it enables you to combine multiple hook libraries and create a piece of state, that is:
Also, I'm not really using reducer as is defined by REDUX/FLUX. I think real reducer should get the action and return new state, that's not what I'm doing. I'm only recieving actions, but modifying the state through callbacks like
setState
. It's a subtle difference I know, but this allows you to use it with react-query and not to keep the whole state on one place as "proper" reducer would.Totally fair and agreed. Sorry if my original comment was a little direct.
Btw, but just to end with 0 chance of doubt, did you try Redux Toolkit before making this or writing the article? 🤔 Curious because of this statement:
Reducers made with Redux Toolkit don't return new state either, they only receive an action and mutate state (thanks to an immer compat layer), exactly like you described 😅 See: redux-toolkit.js.org/api/createSlice
If you weren't aware of that, I wonder if this could explain the confusion from all sides 😅 And if so, I hope this explains why we all feel like you just wrote a 1:1 "lite" version of Redux + Redux Toolkit.
Don't get me wrong I think there are still use cases for REDUX and if I ever find myself in a position that I will need that, I'll definitely try redux toolkit or some similar tool.
However I still don't agree that my solution is the same as redux toolkit. I see redux toolkit as a "syntax sugar" for redux to reduce boiler plate, that's fine. However, my main complain is that REDUX brings unnecessary complexity for majority of projects, you can cover up the complexity with tools like redux toolkit. But from my experience it's better to avoid the complexity from the beginning.
I don't think I've re-built redux toolkit, but if so, that would be actually quite bad sign for redux - think about it ... You take quite a complex library, which you need additional toolkit to be usable and you end up having same solution as writing 30 lines of code.
.
.
.
Last part is exaggerated of course :)
You don't need the toolkit for it to be usable. The toolkit is an opinionated way of setting up and using redux but it's just as easy to use redux without it. The power of the toolkit is that you'll be familiar with all other implementation of the toolkit whereas Redux itself gives you a lot of freedom to set it up however you like.
A good example is the addition of
Immer
in the toolkit. It's absolutely not necessary for Redux but you could implement it yourself without the toolkit if you wanted to.This article would be fantastic.... if it had released 2 years ago before Redux Toolkit came out, which sorted out the boilerplate issue for the most part.
I'd hesitate to call this a more scalable solution than something like Recoil or Redux Toolkit since you still need to have a deep understanding of how context affects rendering and performance. And it's why context will always be better suited for sharing primarily static data that won't change, as opposed to a state management tool. Not to mention that it doesn't account for any of the more advanced state management features like middleware and async operations.
This seems great for prototyping small to medium size projects, but not much else. Still, it's definitely neat and worthwhile mentioning. Too many people feel like they need to use a massive state management library like Recoil or Redux for their hobby project when something leaner could be enough.
I wouldn't say that REDUX scales perfectly with big projects. Having one huge state is not always ideal, especially in cases when you have multi page app, then you have parts of your state, which are completely useless for some pages, but they can still ask data from there, because the state is global. I feel, that having limited context provided only to e.g. one page, scales much better as other pages can't access it even if they want to.
Why would state "ask for data" if the pages are not being used? This sounds less like a Redux problem, and more like an architectural problem caused by bad developer planning. Redux state not-in-use doesn't affect memory or performance in any measurable way. And if it does, it's because you probably are doing some kind needless logic somewhere which could also be caused using the limited context pattern.
This isn't a benefit exclusive to limited context. You can achieve the same results by having individual reducers for each page and not subscribing to global changes from other pages. If you want to have state that can't be accessed by other pages... maybe just don't access it from other pages? You make it sound like because it's global, you are forced to use it everywhere and that's just not true.
What confuses me the most, is your article basically ends with you building a lighter, but more limited version of Redux by combining Context + useReducer + use-context-selector. You basically re-invented the modern wheel, except it's lighter, but harder to repair and can only drive on certain roads (and not very fast). Which is great and impressive as a learning exercise, don't get me wrong. But it's missing important key functionality from global state management systems such as middleware, async operation handling, among other things. And calling it a "redux replacement" is a bit of an overstatement.
Other than that, it's a good first article on Dev.to, congrats!
Sure, it really depends on the project though. I understand that in long lasting projects it is really important to have some strong foundation, where REDUX can give you that, it's a tested approach and you know that it won't kick you.
In our case we wanted to be efficient and reduce unnecessary boilerplate, while keeping the performance and the fact that you can build it yourself pretty easily was quite amazing for me :)
That's fair! I agree that Tolgee seems like a rather simple app where perhaps Redux would have been overkill. Kudos to you for taking the initiative and building your own state management solution, I agree it was probably a fun learning experience.
My feedback was merely directed at that this wasn't a viable solution for Redux for anything larger than small projects 😅 Thanks for understanding. Have a good day!
I don't know what is the size apps you usually build, but I wouldn't say that Tolgee is simple app. Actually we decided to stop using Redux, because it grew too much. ⚰️
redux toolkit is not clean or can claim it uses less boilerplate than, recoil, valtio or similar state managers.
I'm more in the hookstate, recoil camp. I just don't like reducers.
I guess it's a lot about personal taste. My goal was to sum up, how to use context in efficient way - but keep it as simple as possible. I basically understand
dispatch
as a way how to send message to the provider to modify the state. And I like that this way you can pass one function, which can handle all the events, that's how I think about it.But this would mean you would now need to create action creators to ensure it scales properly... which is more boilerplate. Boilerplate you wouldn't need to write with Redux Toolkit slices 🤷♂️ To each their own I guess.
What motivates me further to use Redux is that Redux Toolkit (RTK) provides new feature called RTK Query, so that we can define endpoints more directly and intuitively using createApi (redux-toolkit.js.org/rtk-query/api...), instead of previous ways using createSlice, createAsyncThunk, etc.
I would not use RTK, try Valtio + react query
Yes, but if you think more about it sending a message like
dispatch({type:"add_todo", payload})
you can get the same reducer like behavior with a normal dedicated function like
function addTodo(payload) {
Set...
Set...
do something else...
}
So the more you think about it you will see the complexity is bringing you no benefits.
You can easily do this through context too. I would say, that it's just matter of taste. I'd say I chose dispatch, because I see the symbolic of "dispatching event" for the provider, which is the way I like thinking about it. But I don't really have a strong opinion on this ...
Instead, I prefer Redux toolkit, in any case, we need a kind of complex boilerplate, but with Redux toolkit, I think, that headache was solved. Until Redux toolkit, in small to medium projects, Context API was great for me and it is a part of React, which makes Redux unnecessary but for large projects, I believe currently, Redux toolkit can be accepted as the best, I cannot say it is the best because there can be always a better way.
All of those articles pretend to copy the same names and patterns as redux, so why don’t you go to redux toolkit instead? Contexts solve problems on small projects, but since the complexity is constantly expanding, redux is way simpler to make it more simpler do debug and increment functionalities.
The problem is that I really like using hook libraries like
react-query
and those are not designed to cooperate with REDUX. I think that's also a reason why many people just want to use context, because you keep whole power of hooks in there and you can just pass data to all children.You should check out rtk query. It's part of redux toolkit now and is fantastic. I use the rtk query codegen to automatically create the entire interface with my backend from the swagger. It builds all the redux integration as well as properly typed hooks to use in components.
Yeah... RTK Query seems to make our task in defining endpoints for data fetching easier or more directly/intuitively. Instead of using createSlice and createAsyncThunk for that purpose, we can just use createApi and fetchBaseQuery.
Recoil ftw
Check out recoil on bundle phobia, i wouldn't use it with how much it weights, unless your website shouldn't appear on Google.
Bundle size is important, and there is a reason why recoil is in experimental state.
If they could make it weigh 2kb I might consider it.
Recoil is heavy, but it also saves you from writing a lot of boilerplate code.
However, there are also lightweight (tree-shakable) alternatives
with a similar approach.
I haven't used recoil yet. But from documentation it feels like it's a bit more complicated. I tried to keep it simple and similar to React.Context API. But I guess it has very similar use case ...
Imo using it feels way simpler than context. Different api than context, but very very little setup involved
We implemented this pattern across many Next.js apps without issue but our team decided that we wanted to adopt a state tool. Our choices were between Recoil, Jotai, and Zustand. We ended up going with Zustand and everyone seems to love it. However, I’d really like to ramp up with Recoil.
Nice article!
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)
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.
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.I also use context API and I'm amazed when people are not aware of what it can do and I'm relatively new to the react world. I haven't tried Redux yet and so far I haven't come around to needing it ....YET
People are aware of what it can do, but it's not used because it's well known React Context was not made for sharing frequently changing state. Because React Context triggers re-renders by default (unless you add custom logic to prevent it, like the author), it's frequently a common cause for performance issues.
It's why React Context's main use is to share static, non-changing data.
That's what I said, the possibility of adding your custom logic using the context API is what I'm talking about. What else would I be referring to and my statement wasn't a blanket statement, the key word being "WHEN" because I have encountered people that don't even have an idea about this aspect. Anyways, I am aware of the benefits of using other libraries. But for smaller projects , this will suffice. There's no need to further bloat it up. It comes down to the requirements of the project.
Ah, I see what you mean. The way you worded it made it sound like you thought people didn't know it could be used for state management (given that's what the article you commented on is entirely about). I was simply replying that it's pretty well known it can be used, just that people prefer not to use or write articles about it since the performance considerations make it somewhat of a foot-gun (a gun prone to miss-fires that you shoot yourself in the foot with).
Also worth mentioning, but the performance considerations could also present themselves even in smaller projects, if you have enough expensive-to-render components depending on the context. Meaning even for smaller projects, Context still might not be the best option.
I agree with you that it comes down to the requirements of the project though.
I'd say, that not if you are not using any library is easier to make things messy or slow. It's good to be aware how exactly context work, think about where to use it and how split the state - however if you do that it's quite rewarding.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.