Photo by Austin Chan on Unsplash
Avoid These 5 Common React Context API Mistakes That Could Harm Your App
It’s been more than five years since I’ve started using React. I have worked with many diverse React codebases and have learned some of the most common mistakes that React Developers usually make while using Context API.
The React Context API was released in 2018 to avoid prop drilling by simplifying state management. Here are some quick overviews of the most common misuses of this feature that I have ever analyzed:
Mistake 1: Using an unsafe default value:
You can pass a default value while creating context, might be it seems right to you to pass a default value, but it could be very wrong. Let’s explore how
Suppose, you are giving two properties “count“ and “setCount” to the components that are using your context. You have also added the default value for these while creating a context like this:
const CountContext = createContext({
count: 0
setCount: () => null,
});
It’s a pretty common thing, right?
You now are using this countContext on your components but what suddenly happens, is when you call setCount, it is doing nothing (means not updating count state).
Minutes pass, perhaps even hours, and you still can't figure out what's wrong with your context.
And now you just realized you forgot to consume the context with its provider, therefore the default value that you have provided bites you.
If you carefully understand the problem, the problem is that Context is failing very silently, we should make React Context fail loudly.
Mistake 2: Don’t make Context Fail Loudly:
As we learned, due to the unsafe default value, our react context was failing very silently, what we need to do is make some changes while passing the default value, and also upgrade the way of consuming it on components.
Let’s see how we will initialize it:
const CountContext = createContext(null)
Let’s upgrade the way of consuming context on our component by just updating the name of the hook we used to consume the context. Let’s see:
const useCount = () => {
const value = useContext(CountContext);
if (!value) {
throw new Error("useCount hook can’t be used without CountContext!");
}
return value;
};
Did you notice, why we used the null
as the default value when you consume the count context inside your component, you will get notified by the error (because the ‘value’ will give us null
instead of some unsafe defaults), and you can’t use that without its provider.
Mistake 3: Wrong use of Context Provider causing your whole component hierarchy to rerender everytime
Newbies sometimes misuse the React context provider, causing unnecessary rerenders of all child components. It means that some component that do not depends on the context changes, still rerenders when the context changes.
Let us look at the below code:
const CountContext = createContext(null)
export const App = () => {
const [count, setCount] = useState(0)
const value = { count, setCount }
return (
<CountContext.Provider value={value}>
<main>
<ProductList />
<CounterComponent />
</main>
</CountContext.Provider>
)
}
Let’s understand why it happens:
We all are very well aware of the rule if any component in React let's say “Child” is created inside the “Parent”, then every time “Parent” is re-rendered it re-renders “Child” too.
Why the React is doing that?
Because creating a child inside the parent causes the child component to be recreated every time the parent component is rendered.
In the above code, you can see, that when some context state changes happen on the App component, then all the components inside that will also get rerendered.
How we can solve this problem?
With the help of correctly using component composition patterns.
if “Child” will be rendered inside Parent but not created inside “Parent”, then this problem can be solved.
We can achieve that by passing the <Child />
element as a prop of Parent, React even has a prop created specifically for that called children
When the Parent component is re-rendered, the children
do not re-render. This occurs because children
are not created during the call to Parent, but rather beforehand within the App component.
I have explained this very clearly in a detailed way in my blog post, please have a look at it.
How can we improve our context to handle this, see the below code:
const CountContext = createContext(null)
const useCount = () => {
const value = useContext(CountContext)
if (!value) {
throw new Error('useCount hook can’t be used without CountContext!')
}
return value
}
const CountProvider = ({ children }) => {
const [count, setCount] = useState(0)
const value = { count, setCount }
return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}
export const App = () => {
return (
<CountProvider>
<main>
<ProductList />
<CounterComponent />
</main>
</CountProvider>
)
}
Now, when some state updates occur in the <CountProvider>
component, it will only trigger a re-render for the components that are currently consuming the CountContext
, not the entire component hierarchy.
Mistake 4: Managing multiple independent states inside the single context causing unnecessary rerenders:
Sometimes, we try to manage the state of our whole application using the single Context API, and that is the wrong decision.
Let’s see two fundamental rules of context:
Any component that consumes the context via useContext will re-render when the context changes.
If the component is inside the
<Provider>
but doesn't consume the context, it won't re-render when the context changes.
Often, developers add multiple independent states within a single context, leading to unnecessary rerendering of components that use the context but don't rely on the states causing the rerendering.
Let’s look at the below example:
const AppContext = createContext(null);
const AppProvider = ({ children }) => {
const [count, setCount] = useState(0);
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme((theme) => (theme === "light" ? "dark" : "light"));
};
const value = { count, setCount, theme, toggleTheme };
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
};
export const App = () => {
return (
<AppProvider>
<main>
<ToggleThemeButton />
<CounterComponent />
</main>
</AppProvider>
);
};
const CounterComponent = () => {
const { count, setCount } = useContext(AppContext);
return (
<div>
<p>{count}</p>
<button onClick={() => setCount((count) => count + 1)}>Increment</button>
</div>
);
};
const ToggleThemeButton = () => {
const { theme, toggleTheme } = useContext(AppContext);
return (
<button onClick={toggleTheme}>
{theme === "light" ? "dark" : "light"}
</button>
);
};
Some components are related and are supposed to re-render together while others are completely unrelated.
If you look at the ToggleThemeButton
and CounterComponent
, you'll see that both use the AppContext
, but they don't rely on the same states. This means when you click the increment button and update the count state, it will also cause a rerender for components using the AppContext
for the theme state.
To solve this, we can split the context into two separate contexts: one for the count and another for the theme. Let's look at the example below:
const CountContext = createContext(null)
const CountProvider = ({ children }) => {
const [count, setCount] = useState(0)
const value = { count, setCount }
return <CountContext.Provider value={value}>{children}</CountContext.Provider>
}
const ThemeContext = createContext(null)
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light')
const toggleTheme = () => {
setTheme((theme) => (theme === 'light' ? 'dark' : 'light'))
}
const value = { theme, toggleTheme }
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export const App = () => {
return (
<ThemeProvider>
<CountProvider>
<main>
<ToggleThemeButton />
<CounterComponent />
</main>
</CountProvider>
</ThemeProvider>
)
}
const CounterComponent = () => {
const { count, setCount } = useContext(AppContext)
return (
<div>
<p>{count}</p>
<button onClick={() => setCount((count) => count + 1)}>Increment</button>
</div>
)
}
const ToggleThemeButton = () => {
const { theme, toggleTheme } = useContext(AppContext)
return <button onClick={toggleTheme}>{theme === 'light' ? 'dark' : 'light'}</button>
}
As you can see in the code above, we have separate contexts for theme
and count
. Changes in the CountContext
do not cause any re-renders for components using the ThemeContext
because both are completely independent.
Mistake 5: Incorrect implementation causes components that depend only on the context to re-render even when there is no change in the context value:
I have noticed that certain implementations in various codebases can cause the <Provider>
to re-render. This may happen for several reasons, such as when a context provider relies on changing data passed as props. As a result, all components that consume the context from that <Provider>
will also be re-rendered.
Let’s look at the below example:
const ThemeContext = createContext(null)
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light')
const toggleTheme = () => {
setTheme((theme) => (theme === 'light' ? 'dark' : 'light'))
}
const value = { theme, toggleTheme }
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export const App = () => {
const [count, setCounter] = useState(0);
return (
<button onClick={() => setCounter((c) => c + 1)}>Increment</button>
<ThemeProvider defaultTheme={defaultTheme}>
<main>
<ToggleThemeButton />
<ProductList />
</main>
</ThemeProvider>
)
}
const ToggleThemeButton = () => {
const { theme, toggleTheme } = useContext(AppContext)
return <button onClick={toggleTheme}>{theme === 'light' ? 'dark' : 'light'}</button>
}
In the example above, when the state of the App component updates, it can cause all its child components to re-render. This means the ThemeProvider
will also re-render. When the ThemeProvider
re-renders, it causes all components using its context to re-render as well, even if the context value hasn't changed.
This should not happen. Components using the ThemeProvider
context should only re-render when the ThemeContext
value changes, not just because the ThemeProvider
itself re-renders.
Let’s understand the reason why it’s happening:
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 = { theme, toggleTheme }
Every time this code runs inside the ThemeProvider
due to re-rendering, it creates a new object for the value
variable, even if its content hasn't changed. This is a JavaScript behavior, not specific to React, and it occurs with all JavaScript objects, including arrays.
So, what's the solution to this problem? It's quite simple: we can use memoization to prevent recreating the context value every time. Let’s look at the below code:
const value = useMemo(() => {
const toggleTheme = () => {
setTheme((theme) => (theme === 'light' ? 'dark' : 'light'))
}
return { theme, toggleTheme };
}, [theme, setTheme]);
useMemo
helps us keep the same object reference if its content hasn't changed. As you notice in the above code that we also moved the creation of toggleTheme
inside useMemo
. This saves us from needing to use useCallback
to only memoize the toggleTheme
function.
After making this change, we ensure that as long as theme
and toggleTheme
don't change, we get the exact same object every time we create the context value. let’s see the complete code:
const ThemeContext = createContext(null)
const ThemeProvider = ({ children }) => {
const [theme, setTheme] = useState('light')
const value = useMemo(() => {
const toggleTheme = () => {
setTheme((theme) => (theme === 'light' ? 'dark' : 'light'))
}
return { theme, toggleTheme };
}, [theme, setTheme]);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export const App = () => {
const [count, setCounter] = useState(0);
return (
<button onClick={() => setCounter((c) => c + 1)}>Increment</button>
<ThemeProvider defaultTheme={defaultTheme}>
<main>
<ToggleThemeButton />
<ProductList />
</main>
</ThemeProvider>
)
}
const ToggleThemeButton = () => {
const { theme, toggleTheme } = useContext(AppContext)
return <button onClick={toggleTheme}>{theme === 'light' ? 'dark' : 'light'}</button>
}
After applying memoization in the code above, when the state of the App component updates, the ThemeProvider
re-renders. This time, it only re-renders the components that are using ThemeContext
if the context value has changed; otherwise, no re-rendering will occur for those components.
Here is the complete, optimized code for a context that you can use in your React projects:
import { createContext, useContext, useState, useMemo, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
interface ThemeProviderProps {
children: ReactNode;
}
const ThemeProvider = ({ children }: ThemeProviderProps) => {
const [theme, setTheme] = useState<Theme>('light');
const value = useMemo(() => ({
theme,
toggleTheme: () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
}
}), [theme]);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
};
const useTheme = (): ThemeContextType => {
const value = useContext(ThemeContext);
if (!value) {
throw new Error("useTheme hook can't be used without ThemeProvider!");
}
return value;
};
export { ThemeProvider, useTheme };
Now, I just want to say, if you find this content helpful, please show your support. It can motivate me to write more detailed articles like this.