Something that I learned with the time: Context API
Throughout your software engineering career, I bet you made many mistakes in your beginning as a developer, just the way I did. One of my biggest mistakes was doing the prop drilling. The prop drilling is when you want to make the same state available in many components, probably nested deep inside each other, by passing props through every intermediate component, making your code messy and complicated to maintain. This issue is popularly known as prop drilling.
After misusing state management a few times, I discovered the Context API in React. This strategy is fantastic for managing the global state within your applications. It lets you pass data down the component tree without prop drilling. In this post, I’ll investigate how the Context API simplifies state management in React apps and look at advanced techniques to optimize performance.
When Should I Use the Context API?
Context API is handy when we want to make the same state available in many components, probably nested deep inside each other.
Examples of when should I use it:
- Managing user authentication and permission settings.
- UI theme settings or preference management.
- Sharing global state such as a shopping cart or session information.
How the Context API works
The Context API has three concepts:
- Context: This is where your global data resides, created using React.createContext().
- Provider: A component that passes down the context data to its children.
- Consumer or Hook: The way components access the context data.
We will start with an example using an authentication context.
// AuthContext.js
import React, { createContext, useState, useContext } from 'react';
const AuthContext = createContext();
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const login = (userData) => {
setUser(userData);
};
const logout = () => {
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout }}>
{children}
</AuthContext.Provider>
);
};
export const useAuth = () => useContext(AuthContext);
How to use Context API in your Components
After creating the AuthContext, the next step is to use it in your app. We must wrap our app with the AuthProvider to ensure the authentication state is available anywhere.
// App.js
import React from 'react';
import { AuthProvider } from './AuthContext';
import Home from './Home';
function App() {
return (
<AuthProvider>
<Home />
</AuthProvider>
);
}
export default App;
Now, the state of authorization and functions to sign in and log out are available for all components:
// Home.js
import React from 'react';
import { useAuth } from './AuthContext';
const Home = () => {
const { user, login, logout } = useAuth();
return (
<div>
{user ? (
<div>
<h1>Welcome, {user.name}!</h1>
<button onClick={logout}>Logout</button>
</div>
) : (
<button onClick={() => login({ name: 'Artur' })}>Login</button>
)}
</div>
);
};
export default Home;
Reducing Context API Re-renders
The Context API is a beneficial solution, but we must be careful due to potential performance issues. One key issue is unnecessary re-renders. When the value passed by the Provider changes, all components that use the context are re-rendered again, even if they only need a small part of the data. Fortunately, there are strategies to optimize performance and prevent these issues.
Strategy 1: Memoize the Provider’s Value
One of the simplest ways to avoid unnecessary re-renders is by memoizing the value passed to the Provider using useMemo. Without memoization, the Context API will create a new object for the value on every render, causing all components consuming the context to re-render, even when only part of the state has changed.
Let’s compare two examples: one without useMemo and another with useMemo, demonstrating the performance impact.
Example Without useMemo:
import React, { useState, createContext, useContext } from "react";
const CountContext = createContext();
const CountProvider = ({ children }) => {
const [count, setCount] = useState(0);
const [count2, setCount2] = useState(0);
const increment = () => setCount(count + 1);
const increment2 = () => setCount2(count2 + 1);
const value = { count, count2, increment, increment2 };
return (
<CountContext.Provider value={value}>{children}</CountContext.Provider>
);
};
const DisplayCount = React.memo(() => {
const { count } = useContext(CountContext);
console.log("DisplayCount re-rendered");
return <h1>Count: {count}</h1>;
});
const IncrementButton = React.memo(() => {
const { increment } = useContext(CountContext);
console.log("IncrementButton re-rendered");
return <button onClick={increment}>Increment</button>;
});
const IncrementButton2 = React.memo(() => {
const { increment2 } = useContext(CountContext);
console.log("IncrementButton re-rendered");
return <button onClick={increment2}>Increment</button>;
});
const App = () => {
return (
<CountProvider>
<DisplayCount />
<IncrementButton />
<IncrementButton2 />
</CountProvider>
);
};
export default App;
In this example, every time the count or count2 changes, all components consuming the context (DisplayCount, IncrementButton, and IncrementButton2) are re-rendered, leading to performance inefficiencies. This happens because the value passed to the Provider is being recreated on each render, triggering unnecessary re-renders.
Example With useMemo:
import React, { useState, createContext, useContext, useMemo } from "react";
const CountContext = createContext();
const CountProvider = ({ children }) => {
const [count, setCount] = useState(0);
const [count2, setCount2] = useState(0);
const increment = () => setCount(count + 1);
const increment2 = () => setCount2(count2 + 1);
const value = useMemo(
() => ({ count, count2, increment, increment2 }),
[count]
);
return (
<CountContext.Provider value={value}>{children}</CountContext.Provider>
);
};
const DisplayCount = React.memo(() => {
const { count } = useContext(CountContext);
console.log("DisplayCount re-rendered");
return <h1>Count: {count}</h1>;
});
const IncrementButton = React.memo(() => {
const { increment } = useContext(CountContext);
console.log("IncrementButton re-rendered");
return <button onClick={increment}>Increment</button>;
});
const IncrementButton2 = React.memo(() => {
const { increment2 } = useContext(CountContext);
console.log("IncrementButton re-rendered");
return <button onClick={increment2}>Increment</button>;
});
const App = () => {
return (
<CountProvider>
<DisplayCount />
<IncrementButton />
<IncrementButton2 />
</CountProvider>
);
};
export default App;
In this example, only the components that depend on count are re-rendered when count changes. IncrementButton2 is not affected because useMemo ensures that the context value is only recalculated when count changes. This significantly reduces unnecessary re-renders, improving performance.
Analyzing the React DevTools Flamegraph
We can visualize the performance improvements using React DevTools flamegraph. When memoization is not applied, all components are re-rendered when the context value changes, regardless of which part of the state was updated. However, after applying useMemo, only the components that rely on the updated state are re-rendered. Here’s the breakdown:
- Without useMemo: All components (DisplayCount, IncrementButton, IncrementButton2) are re-rendered even when only count changes, leading to inefficient performance.
- With useMemo: Only DisplayCount and IncrementButton (which depend on count) are re-rendered, while IncrementButton2 remains unaffected because count2 hasn’t changed.
Strategy 2: Decouple Contexts by Responsibility
Another way to optimize the use of Context API is to split your contexts based on responsibility. For example, if your context manages multiple states such as authentication, theme, and settings, it’s better to split them into separate contexts. This way, each context manages only its own data, reducing the likelihood of triggering unnecessary re-renders across unrelated parts of the application.
Context API vs Redux: Which One to Use
The Context API solves many issues related to global state management. However, you may need Redux or another state management library for more complex and larger projects. Redux provides more advanced tools, such as middleware, that handle async logic, better approach to the state flow, and solid debug tools.
When to use Context API:
- Use Context API if the global state in the application is relatively simple and you need to pass minimal data.
- Use Context API if there’s no need for complex state management tools.
When to use Redux:
- Use Redux if your project is in a complex global state and has many side effects.
- Use Redux if you need more advanced state management capabilities, like middleware and logging.
Different Libraries
While the Context API is great for managing global state, other libraries and hooks can offer more specialized solutions. Two popular alternatives are Zustand and useSyncExternalStore.
1. Zustand
Zustand is a lightweight state management library for React. It’s simpler than Redux and provides better control over re-renders, as components only react to the state they need.
2. useSyncExternalStore
useSyncExternalStore is a React 18 hook for managing state from external sources, like APIs or WebSockets. It ensures React stays in sync with external data, making it ideal for real-time updates.
Which One to Choose?
- Context API: Best for simple, small-scale state management.
- Zustand: Ideal for centralized state without the complexity of Redux, while avoiding unnecessary re-renders.
- useSyncExternalStore: Perfect for syncing components with external sources like APIs or WebSockets.
Each solution fits different needs, so choosing the right one depends on your project’s complexity and requirements.
I hope this post helps you understand how to start using Context API and when to use it. Now that you know the benefits of this strategy and potential performance issues, you will never need prop drilling again.