That’s right, you’ve read it correctly, you are probably using React Context API the wrong way… or at least not taking full advantage of what it can do. I know it might sound extreme, but believe me there is a series of anti-patterns and bad practices that we end up using over and over with React Context API without ever noticing they are problematic. So in this article, I want to show some of those practices, the problems they cause, the reason for those problems, and how to solve them. If you never used the React Context API before, take some time to learn it and experiment with it first, and then come back to this article, you might avoid a lot of mistakes. Let’s go!
Using an unsafe default value
The React Context API allows a default value for a context to be provided in case you try to consume a context without its provider having being rendered up the components tree. Sounds neat, right? WRONG. Even though according to the docs this is well-intentioned and supposed to help at testing, most of the time it’s a footgun.
Let’s imagine you’re building a simple form app where the user types the name of their favorite book and the interface shows it.
We decide to use a Context to hold and distribute the state management of the app, so your context will expose an object containing the book and a function to change its name. Since you’re required to provide a default value, you set it to contain a book with an empty name and the function that changes the name of the book just does nothing:
// BookContext file
const BookContext = createContext({
// fake default value
book: { name: '' },
changeName: () => null,
});
Pretty common, right? Awesome! You then begin to implement the component that will consume this context, you even add a custom hook!
// BookContext file
const useBook = () => {
const value = useContext(BookContext);
return value;
};
// ----
// BookForm file
import { useBook } from './BookContext';
const BookForm = () => {
const { book, changeName } = useBook();
return (
<div>
<h1>Book: {book.name}</h1>
<input value={book.name} onChange={(e) => changeName(e.target.value)} />
</div>
);
};
You write tests for your component and your hook and they run perfectly, you render them directly inside the original BookContext.Provider
in the tests to make sure they consume the context correctly:
render(
<BookContext.Provider value={{ book: { name: 'The Silmarillion' }, changeName: jest.fn() }}>
<BookForm />
</BookContext.Provider>
)
And here’s where the default value for the context begins to piss you off.
Your tests pass, but for some reason when you run the actual app you’re not able to change the name of the book, and it looks like the changeName
function you created isn’t even called when you put a console.log
there to make sure, what’s going on?! You furiously check the implementation of your component, you check the docs for the rules of hooks to make sure you didn’t break any of them, but no dice!
You can interact with a demo of this application here.
Several minutes pass (maybe hours) and you can’t for the life of you find what’s wrong with your context… until you find it.
Yeah, you know what was it… you forgot to render your provider and the app was just always using the default value that you so cleverly set back then. Remember when it sounded like a good idea?
There it is, you now see why this is such a footgun. "But what can we do", you might wonder, "we still need to work with scenarios like those"! And you are correct about it, the problem there is not the scenarios that we need to handle, but the fact that React fails very silently when it uses default values for contexts. So we’ll change it… let’s make React fail LOUDLY.
Making React Context fail loudly
First of all, let’s recap the reason why this happened. Since you didn’t have any state to provide or modify when you were creating your context you had to provide a book with an empty name and an implementation for the changeName
function that, for the lack of a better option, would do nothing. This is where your problems began, you lied to the consumers of your context, let’s solve it.
We’re going to assume that implementations that go down the "for the lack of a better option" road are bad. In this case, since we don’t have any state to provide or modify when creating the context, we’re going to use null
for the whole thing instead. From there, we will use this null
value to allow us to know when to fail loudly, when to make React scream "YOU FORGOT SOMETHING"! So we go to the definition of our context and change it to be like this:
const BookContext = createContext(null);
And now enters the trick: we don’t need to change anything else in our component if we are already providing the correct context value there, what we need to do now is change our hook! The hook will be the one responsible for knowing if we’re trying to access the context without the provider screaming at us for that:
const useBook = () => {
const value = useContext(BookContext);
if (!value) {
throw new Error('π£οΈ useBook hook used without BookContext!');
}
return value;
};
See what we did there? Now whenever we dare to consume a context without rendering its provider (and we know it because the value is not there), our hook will scream at us!
You can interact with a demo of this solution clicking here. Notice that there are some instructions in the App.tsx
file for you to try.
This approach is very common in libraries to ensure they are being used correctly, for example:
- TanStack Query
- React-Redux
- React Router (it does not follow the exact same implementation, but it uses the same approach)
Causing unnecessary re-renders
Time passes and some of our million users complain that the book app needs a dark mode because the app is so cool that they want to be able to use it at night before going to sleep, so who are we to deny them this feature? Let’s build it!
We add some CSS classes and then change the code for the App
component to toggle between them and make the dark mode work:
export const App = () => {
const [theme, setTheme] = useState('light');
const toggleTheme = () => {
setTheme((theme) => (theme === 'light' ? 'dark' : 'light'));
};
const [book, setBook] = useState({ name: '' });
const changeName = (name) => {
setBook((book) => ({ ...book, name }));
};
const value = { book, changeName };
return (
<BookContext.Provider value={value}>
<main className={theme}>
<ToggleThemeButton theme={theme} toggleTheme={toggleTheme} />
<BookForm />
</main>
</BookContext.Provider>
);
};
const ToggleThemeButton = ({ theme, toggleTheme }) => {
return (
<button onClick={toggleTheme}>{theme === 'light' ? 'π' : 'π'}</button>
);
};
Yay, we have dark mode now! You can click here to see it working.
Our job here is done, right? It’s impossible to find issues in this app, it’s so simple! WRONG
What if I told you our app has a serious issue that is caused by the innocent code shown above? Can you guess what is it? Go check again!
It might not be clear in the snippet, but this issue is the one that people miss the most: causing parts of our app to re-render for reasons unrelated to the data necessary in that part. The cause for a lot of people never noticing this issue is that this problem is silent, it usually won’t break anything, you will only notice it when you profile your frontend application and notice that you have a lot more DOM changes after a user interaction than you’d expect.
Let’s add some console.log
s and see the problem happening. Here’s a GIF showing it:
Notice that when we change the theme, the BookForm
component is re-rendered and the useBook
hook is called again even though they don’t depend on the theme. In a similar sense, notice that when we change the name of the book, the ToggleThemeButton
component is re-rendered even though it does not depend on anything related to the book.
Click here to see a working demo of this problem, now with logs.
So now we have two problems to solve:
- Something that does not depend on the context re-renders when the context changes
- Something that only depends on the context re-renders when something that is not the context changes
Let’s tackle them one by one!
Something that does not depend on the context changes when the context changes
Before solving this problem, let’s understand the reason why it happens. Even though part of the process of React rendering a component consists of calling the function that defines that component, it’s not the whole thing, that’s why when you use a component with the JSX syntax <MyComponent propA={1} />
, it’s not the same as you calling MyComponent({ propA: 1 })
directly. When we use the JSX syntax, this is equivalent to doing React.createElement(MyComponent, { propA: 1 })
, and we say that the value returned by this function is an element, we can interpret it as if it was an "instance" of our component.
Since our components are made of other custom components and native components (div
, input
, …), when a component is rendered, all of its content is also called using the same logic of the previous paragraph, creating elements for all of them. After that, a component will be re-rendered if a change of state that affects that component happens, and everything created inside of it will be re-rendered too.
Let’s see a quick example of this:
export default function App() {
return <Parent />;
}
const Parent = () => {
const [state, setState] = useState(0);
console.log('%c Parent rendered!', 'color: yellow');
return (
<>
<button onClick={() => setState((state) => state + 1)}>
Click me to rerender
</button>
<Child /> {/* Notice that Child is created INSIDE the Parent component */}
</>
);
};
const Child = () => {
console.log('%c Child rendered!', 'color: #3bdbc6');
return null;
};
Click here to see the live demo of this example.
As you can see, every time Parent
is re-rendered it re-renders Child
too, because it is created inside Parent
.
But what would happen if Child
was rendered inside Parent
but not created inside parent? We can achieve that by passing the <Child />
element as a prop of Parent
, React even has a prop created specifically for that: children
. So let’s change our example to do that:
export default function App() {
return (
<Parent>
<Child />
</Parent>
);
}
const Parent = ({ children }) => {
const [state, setState] = useState(0);
console.log('%c Parent rendered!', 'color: yellow');
return (
<>
<button onClick={() => setState((state) => state + 1)}>
Click me to rerender
</button>
{children} {/* This is where Child will be rendered, but it is not CREATED here */}
</>
);
};
const Child = () => {
console.log('%c Child rendered!', 'color: #3bdbc6');
return null;
};
Click here to see the live demo of this example.
Interesting! Now, when Parent
is re-rendered, Children
is not re-rendered! It happens because Children
is not created when Parent
is called, but before it, inside the App
component.
Right, now we have all the tools to fix the problem we want to fix, which is: Something that does not depend on the context changes when the context changes.
If you look closely at the code we produced before, you’ll see that we had the context provider and the theme rendering in the same place, and also the state of both of them lived in the same place, all inside the App
component. We will begin changing that by creating a custom provider that will be responsible only for the state management of the book:
// BookContext file
const BookProvider = ({ children }: { children: ReactNode }) => {
const [book, setBook] = useState<Book>({ name: '' });
const changeName = (name: string) => {
setBook((book) => ({ ...book, name }));
};
const value = { book, changeName };
return <BookContext.Provider value={value}>{children}</BookContext.Provider>;
};
Ok, you can draw a parallel between this BookProvider
corresponding to the Parent
component of the previous example. So instead of just changing the App
component to use BookProvider
where BookContext.Provider
was, we will remove it from there and move it to be rendered around the App
component, in the index
file!
// index file
root.render(
<BookProvider>
<App />
</BookProvider>
);
Let’s see the result:
Click here to see the live demo of this example.
Alright, that’s awesome! Now, when the context changes, the only parts of our app that are re-rendered are the ones that depend on the context. This is good!
If you read until here, know that this technique is the one that brings the most performance gain at a low cost when using Context API in an app, but in case you want to go further, let’s tackle the next issue.
Something that only depends on the context changes when something that is not the context changes
As you can see, even though the previous change prevents the ToggleThemeButton
component from being re-rendered when the book is changed when we change the theme, anything related to the book is re-rendered, but it shouldn’t. There are a handful of ways to fix that, but here we will focus on two possibilities, each for a different situation.
The first of them is in case you have control over where your context providers will be rendered and they can be moved up the component tree. If you do, you can fix it by moving the theme logic to a different custom provider, just like we did in the previous change, and nest it as close as possible to the root of your application, where no re-rendering will be caused, so you’d have something like this:
// index file
root.render(
<BookProvider>
<ThemeProvider>
<App />
</ThemeProvider>
</BookProvider>
);
Click here to see the live demo of this example.
The second situation is when you can’t control where your providers are rendered (for example, if you’re writing a library) or if you can’t move your context up the component tree (for example, when you have a context provider that depends on some changing data received as a prop). This situation prevents you from putting your provider close to the application root, so it requires a different approach!
Before diving deeper, let’s change our example to make it look like the situation we just described. We will modify our index file to be re-rendered for some reason that is not related to the book:
// index file
const Main = () => {
const [_, setCounter] = useState(0);
return (
<>
<button onClick={() => setCounter((c) => c + 1)}>Click me</button>
<BookProvider>
<App />
</BookProvider>
</>
);
};
root.render(<Main />);
With the code above, see what happens when we click the button:
Ok, we can see that we have the exact problem we just described: we have components that re-render after changes to data they don’t depend on. Now, back to the solution.
So, what we want is that, as long as the data that the component depends on is the same, it should not be called again. Wait a second, we do have a concept in computer science that tackles this situation, don’t we? Yes! it’s called memoization.
Memoization is a technique where the results of a function call are cached and reused when the same inputs occur again to avoid redundant computations.
And guess what, React does have native support to memoize components. It’s a function called memo
. Using it should be pretty straightforward: we mostly just call the memo
function passing the component we want to memoize. And that’s what we will do with the BookForm
component:
// BookForm file
const BookForm = memo(() => {
console.log('BookForm called');
const { book, changeName } = useBook();
return (
<div>
<h1>Book: {book.name}</h1>
<input value={book.name} onChange={(e) => changeName(e.target.value)} />
</div>
);
});
That should suffice, right? WRONG. Even though we fixed the initial scenario of having the BookForm
component be re-rendered after the theme changes, we still have the problem of having everything be re-rendered when the button is clicked, and, in this case, we can’t move the behavior up, remember?
Click here to see the live demo of this example.
What happened?! I will tell you what happened and why the component that depends on the BookContext
re-rendered: memoization only works when the input (in this case, the context’s value) changes, right? What if I tell you that the value of the context is changing? Don’t believe me? Well, bear with me.
The core reason for this problem is that we re-create the context value every time the provider re-renders. Notice that when we create the context value, we do this:
const value = { book, changeName };
Every time this code is called inside the component, it will make the value
variable be a different object, even when its content remains the same. This is a JavaScript thing, not a React thing, and it happens with every JavaScript object, including arrays. To check it out, open the Console of your browser and try these:
// notice that this returns false
const a = { codeminer: 42 };
const b = { codeminer: 42 };
a === b // false
// and this one also returns false
[1, 2, 3] === [1, 2, 3] // false
In the old React documentation there used to be a section about this problem which, for some reason, I could not find in the new React documentation, as it apparently was replaced by recommending to have a separate context for each value you want to expose, even when they are related, which I think it generates a lot of unneeded boilerplate.
But now I want to present you a better way to do it without that amount of boilerplate: more memoization. We need to memoize the creation of the context value.
To memoize the creation of the context value, we will use useMemo
, which allows us to always have the reference to the same object if none of its content changed. The code that creates the context value will be changed to be like this now:
const value = useMemo(() => {
const changeName = (name) => {
setBook((book) => ({ ...book, name }));
};
return { book, changeName };
}, [book, setBook]);
As you can notice, we also moved the creation of changeName
to inside the useMemo
, as it will spare us from having to use useCallback
to memoize just the changeName
function.
Right, so after this change, we now ensure that as long as book
and setBook
don’t change, every time we create the context value, we get the exact same object.
Now, after these changes, we can see that even though the provider is called again every time we click the button, the BookForm
component will not be re-rendered because the context value is the same object as before:
Click here to see the live demo of this example.
Yay, we did it!
Now, you might have noticed that the problem is still happening to the theme context and components, right? Well, the fix for this is the same as the one we just did, so I will leave it up to you to implement it using what you just learned.
Wrapping up
And that’s it! The idea for this article was to show that even things that look simple, like React Context, can be misused if you don’t think about or understand it thoroughly, and sometimes, even if you do, it might bite you in unexpected ways, especially when we’re so used to doing something in some way that we don’t even bother to stop to consider why we are doing it that way. Also, none of the things I told here are secrets. Most of them can be found in the React documentation, but I know sometimes we don’t read the docs if we think the things we’re dealing with are simple, so pay attention to that as well. Now get out of here and start using React Context the RIGHT way!
We want to work with you. Check out our "What We Do" section!