Remember when Redux was everyone's favorite tool for handling state in React apps, helping us avoid the dreaded prop-drilling?
This tool came to life in 2015, thanks to Dan Abramov and Andrew Clark. Abramov was gearing up for a talk at React Europe when he started working on a Flux proof of concept that could handle state changes dynamically, allowing for forward and backward time travel through state alterations. This endeavor led to the birth of Redux.
Fast forward to 2016, Abramov shared a piece of advice that would resonate with many developers:
"I would like to amend this: don't use Redux until you have problems with vanilla React."
Within that same year, he joined the React core team and introduced the world to the React Context API. This release marked a shift in the developer community, with many trading their boilerplate-heavy Redux setups for sleek React Context solutions.
I have fond memories of using Redux Devtools in my larger projects. Yet, I can't deny the breath of fresh air that came with bypassing Redux's extensive setup in my newer ventures. Join me as we take a closer look at React Context, examining its pros and cons, and exploring a route to even smoother state management, leaving Redux out of the equation.
Understanding React Context
Before we delve deeper, let's take a moment to appreciate the inception of React Context. It emerged as a solution to simplify state management and mitigate the issues of prop-drilling that were prevalent in large applications.
React Context shines in reducing prop-drilling, a situation where props are passed down through many layers of components. Let's illustrate this with a more detailed example.
Without React Context
function App() {
const [user, setUser] = useState({ name: "John Doe" });
return <Header user={user} />;
}
function Header({ user }) {
return <Navbar user={user} />;
}
function Navbar({ user }) {
return <UserProfile user={user} />;
}
function UserProfile({ user }) {
return <div>User name: {user.name}</div>;
}
In this scenario, the user
prop is being passed down through several layers, from App
to UserProfile
, even though the intermediate components (Header
and Navbar
) do not use the user
prop.
With React Context
const UserContext = React.createContext();
function App() {
const [user, setUser] = useState({ name: "John Doe" });
return (
<UserContext.Provider value={user}>
<Header />
</UserContext.Provider>
);
}
function Header() {
return <Navbar />;
}
function Navbar() {
return <UserProfile />;
}
function UserProfile() {
const user = useContext(UserContext);
return <div>User name: {user.name}</div>;
}
Here, we create a UserContext
and use it to provide the user
value directly to the UserProfile
component, bypassing the need to pass it through the Header
and Navbar
components. This approach maintains a cleaner, more manageable codebase, effectively reducing prop-drilling.
React Context Shortcomings
While React Context has brought a considerable amount of ease in state management, it comes with its own set of challenges. Let's delve into some of the notable drawbacks along with code examples to illustrate them.
Re-rendering Inefficiencies
One of the significant issues with React Context is that it causes unnecessary re-renders. Whenever the context value changes, all components consuming the context are re-rendered, even if they only use a part of the context value, leading to performance bottlenecks.
const UserContext = React.createContext();
function App() {
const [user, setUser] = useState({ name: "John", age: 30 });
return (
<UserContext.Provider value={user}>
<UserProfile />
<UserAge />
</UserContext.Provider>
);
}
function UserProfile() {
const user = useContext(UserContext);
console.log("UserProfile rendered");
return <div>User name: {user.name}</div>;
}
function UserAge() {
const user = useContext(UserContext);
console.log("UserAge rendered");
return <div>User age: {user.age}</div>;
}
In this example, changing either the name
or age
property of the user
state would cause both UserProfile
and UserAge
components to re-render, even if only one of the properties is used in each component.
This isn't really a big deal in 99% of use cases. The Redux fanboys will boldly proclaim that Redux has a built in solution to this with their useSelector
hook.
Wrapper Hell with Multiple Context Providers
As applications grow in complexity, it's common to find ourselves needing multiple contexts to manage different aspects of the application state. This can lead to a "wrapper hell," where our main App component is wrapped in several layers of context providers, leading to a cluttered and less maintainable codebase.
const UserContext = React.createContext();
const ThemeContext = React.createContext();
const LanguageContext = React.createContext();
function App() {
const [user, setUser] = useState({ name: "John Doe" });
const [theme, setTheme] = useState("light");
const [language, setLanguage] = useState("en");
return (
<UserContext.Provider value={user}>
<ThemeContext.Provider value={theme}>
<LanguageContext.Provider value={language}>
<Dashboard />
</LanguageContext.Provider>
</ThemeContext.Provider>
</UserContext.Provider>
);
}
function Dashboard() {
const user = useContext(UserContext);
const theme = useContext(ThemeContext);
const language = useContext(LanguageContext);
return (
<div style={{ background: theme === "light" ? "#fff" : "#333", color: theme === "light" ? "#000" : "#fff" }}>
<h1>{language === "en" ? "Hello" : "Hola"}, {user.name}</h1>
</div>
);
}
In this example, the Dashboard
component needs to access multiple contexts, resulting in a deeply nested structure of providers in the App
component. This not only makes the code harder to read but also increases the complexity of managing state as the number of contexts grows.
The Redux fanboys also love to bring this one up and I can admit Redux handles this much better by only having one provider to rule them all.
Boilerplate Code
Although React Context does not have nearly as much boilerplate as Redux does. It can still be annoying to have to set up a provider to wrap around your code whenever you want to use React Context.
Yeah, the Redux fanboys can shut up for this part.
The Solution: Zustand
Zustand is a small, fast, and scaleable "bearbones" state-management solution. It is not strictly tied to React, which means it can be used with other frameworks as well. It offers a simple and intuitive API, facilitating easy integration into your projects.
Here's are some of the benefits on why Zustand stands out:
Simplified State Management
Zustand does away with the need for reducers, actions, and context providers, offering a straightforward way to manage state. It allows for a clean and minimal setup, helping you avoid the "wrapper hell" we often encounter with multiple context providers.
Avoids Unnecessary Re-renders
Zustand ensures that components only re-render when the state they are subscribed to changes, avoiding the unnecessary re-renders that are common with React Context. This is similar to the way Redux handles this problem with its useSelector
hook.
Easy to Set Up and Use
Setting up Zustand is a breeze. With just a few lines of code, you can have your state management up and running, without the boilerplate code that comes with other solutions.
Implementing Zustand: A Practical Example
Let's look at how we can implement Zustand in a React project, using a simple counter example:
import create from 'zustand';
const useStore = create((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const { count, increase, decrease } = useStore();
return (
<div>
<button onClick={decrease}>-</button>
<span>{count}</span>
<button onClick={increase}>+</button>
</div>
);
}
function App() {
return (
<div>
<Counter />
</div>
);
}
In this example, we define a useStore
hook using Zustand's create
function, where we define our state and actions. In the Counter
component, we then use this hook to access and update the state, demonstrating a simple yet powerful state management solution with Zustand.
Leveraging Middleware in Zustand
A significant advantage of using Zustand is its compatibility with various middlewares that enhance its functionality, providing a rich and flexible development experience. Here, we briefly touch upon some of the notable middlewares you can leverage:
Persist Middleware: Allows for the persistence of your store's state, saving it in a storage solution of your choice and rehydrating it when the page reloads. It's a great tool for maintaining state persistence across sessions.
Immer Middleware: Facilitates working with immutable state, letting you write mutable logic safely. It integrates seamlessly with Zustand, providing a straightforward way to manage complex state logic while maintaining immutability.
Redux Middleware: If you are coming from a Redux background, you'll appreciate this middleware. It enables you to use Redux-like reducers and actions in your Zustand store, providing a familiar development experience.
Devtools Middleware: This middleware integrates Zustand with Redux DevTools, allowing you to inspect your state and actions, offering a powerful debugging tool that many developers are already familiar with.
These middleware options enhance Zustand's functionality, offering a flexible and powerful solution for state management in React applications. They can be great allies in building robust and maintainable applications, providing tools to handle a variety of complex state management scenarios efficiently.
Conclusion
If you haven't used Zustand before, then I hope I've convinced you to give it a try to enhance your developer experience, and if you have used it, let me know your thoughts on it.
Please share your experiences and thoughts in the replies below for some healthy, constructive discussion.
Icebreaker question:
What do you currently use for state management in your React apps?