Using React contexts are easy, designing them are harder. In this article we will therefore look at best practices and what you have to watch out for not to fall in the context trap.
Whatever code we ever invent we always seem to end up in hell. In the case of React contexts we have the context hell, which refers to the problem of bloating the code with a lot of context providers at the root level of you code.
// Example of context hell.
const ContextHellApp = () => (
<>
<ReduxProvider value={store}>
<ThemeProvider value={theme}>
<AnotherProvider value={anotherValue}>
<YetAnotherProvider value={yetAnotherValue}>
<GetItYetProvider value={yeahIGetItValue}>
<FinallyAComponent />
</GetItYetProvider>
</YetAnotherProvider>
</AnotherProvider>
</ThemeProvider>
</ReduxProvider>
</>
)
As you can see in the link, there's a suggested solution to it. Quite an easy solution for a quite minor issue. The context discussion shouldn't end there though. To me the context hell isn't the real issue here. What I consider problematic with contexts is what I like to call the context trap.
Keep Your Contexts Small
Whenever a React context i being updated, all components using that context will rerender. To avoid unnecessary renderings, one should keep the contexts as small as possible.
By doing that, you will not only render components less often, you will also be able to move your context provider components further down the React tree if you only need them for parts of your application, which will save you from the context hell mentioned earlier.
// Context hell isn't a problem when keeping contexts small.
// If only parts of the app use a context, we can lift it down
// to a component within <Component /> or even further down.
// Redux and theming affect the whole application, so we keep
// them here in the top-level component.
const SmallContextsApp = () => (
<>
<ReduxProvider value={store}>
<ThemeProvider value={theme}>
<Component />
</ThemeProvider>
</ReduxProvider>
</>
)
Moving down context providers may not be necessary, but it can help developers understand what parts of the application that actually are affected by the context. On the other side, it may not be a great solution, since using that context outside of the provider will give birth to a bug.
Anyhow, I know, you are a good developer, you already knew you should keep your contexts small. You always keep your contexts as you keep your project's bundle size, slim fit.
Introducing Context Trap
Times changes, and suddenly you need a connection between two of your contexts. Maybe you have divided chats and messages into two separated contexts and you now need to look at the chat when you receive a new message?
Nah, you would never split chats and messages into different contexts. But maybe your incautious colleague Joey would? It's always Joey...
Let's say Joey didn't mess up this time. You both did the correct choice, keeping chats and messages in the same context, they are related after all. But what about the user context? You do have users on your site, don't you? Should that context be connected to the chat-and-messages context?
You will need to know which users are members of a chat, and you will have to know which chats a user is a member of. Maybe you even add a subsystem for reporting user misbehavior. Should that be stored in its own context?
These are real questions you are likely to face someday and you may have forgotten to plan for it. There are of course good solutions of how to handle these cases, many times it can be solved by handling things differently in backend. Sometimes you have no other choice than handling it in frontend. Either way, remember that if you choose to split your global store into multiple independent contexts you may get into trouble, that's what I refer to as context trap.
What Is the Problem?
The context trap isn't something you easily can solve by moving or adding a few lines of code. Accessing contexts within another context isn't a very great idea, so you will likely have to handle all cross-context logic outside the contexts.
By cross-context logic I mean the logic required to ensure that two or more contexts stay in sync with each other, e.g., updating messages count in a chat context when a new message has been added to a context for messages.
Syncing contexts will include reading and dispatching actions to all affected contexts at the right time in the correct order. Writing such logic is like building a trap for all newly hired employees to fall in, not just Joey. It may be easy to write in the first place, but scaling and maintaining it is a hell.
When you have multiple contexts you need to update whenever an event emits, such as receiving a new chat message, you will have to know how the complete code works in order to know when and where you should update your contexts.
Newly employed developers are often unaware of all the contexts that need to receive updates, so they will most likely introduce a bug. They might notice the bug and try to solve it. What happens then is that most developers blindly try to fix that single bug instead of trying to grasp how the complete solution works and suddenly something else has broken.
Development goes on and after a year it isn't an issue for new employees only, even you get a headache looking at the code. You end up with a code base that is just about a year old and you have already added a task in you backlog to refactor it.
Yes Joey, that's what git push master means
Contexts Is Not Always the Solution
So, how to avoid falling into this trap? Well, one alternative is to follow Juan Cortez's rule number 9, to always use the right tool for the job.
Rule no. 3 is unfortunately often true as well
Contexts aren't the solution to all issues. It shouldn't be considered as a "lightweight" Redux. Context and Redux is not interchangeable. They have different use cases. Redux is a full state management system and in complex applications you may be better off using that.
How Can Redux Save Us From the Context Trap?
How could Redux help us out here? You still have to make a lot of design choices; you can't escape that. The benefit arises when you are writing your code. When using contexts, you are free to do any mistake you want (or don't want) to do.
Moreover, when multiple developers work on a project, many of them will find it hard to read code written by other teammates, especially when the code isn't implemented with readability in mind. Redux solves both these problems for us and many more problems we need to handle ourself when using a context. Here's a few examples coming up on my mind at the moment.
- With Redux you are less likely to do mistakes in your code since you are basing your code on guidelines and well documented code.
- You don't need to write all that code Redux handles for you. Redux has been developed for a long time and has been tested well. Writing you own code in replacement of Redux will most likely introduce more bugs in your code.
- With Redux, dispatched actions are by default passed to each reducer. There's no need to manually dispatch actions to all affected contexts or removing such logic whenever a context doesn't need that information anymore.
- Redux reducers can access the complete store. If you replace that with a multiple-context solution you will need to handle that in some other way. If you are using a useReducer in a context in replacement of Redux you will have access to that context only, not any of the other contexts.
- Developers know how Redux works. They can quickly find all the places in the code where a specific action is dispatched, or vice versa, all the reducers that are affected by an action. You don't necessarily get that when you design your own solution. Joey may already know Redux, but he will definitely find a way messing up your custom-made code.
Conclusion
When you consume a context using useContext hook, your component will rerender any time that context updates. So even if your component merely grabs a user's name from the context, it will still rerender as soon as any change at all is made to the context. When designing React contexts, you should therefore use many small contexts rather than one massive one to avoid unnecessary rerenders.
Breaking your contexts into smaller parts may however lead to complex code when your application grows. It's not for certain, but making the wrong design choices can put you into some real trouble. That's what I refer to as the context trap, splitting contexts into smaller pieces and ending up with code that is hard to read and maintain.
Suggested solution from my side is to use Redux. You don't always need Redux, but if you start to think about using multiple contexts or if you want to put a reducer in a context, you should probably consider using Redux.
Thanks for reading,
Dennis Persson