From Redux to React Context and Beyond: A Dive into State Management with Zustand

Barry Michael Doyle - Sep 15 '23 - - Dev Community

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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?

. . . . . . . . . . .