Home / Articles / Prop Drilling Is Not a Reason to Install Redux or Zustand

This article is published in English.

Prop Drilling Is Not a Reason to Install Redux or Zustand

Test four common reasons for adding a state library against working React code: Context for prop drilling, useState, useSyncExternalStore, and Context's re-render cost.

2370 words

Ask a team why Redux, Zustand or MobX is in their React app and prop drilling is usually the first answer. It is a real annoyance, but it is not a good reason to add a dependency, and neither are the three justifications that tend to follow it. Below, each of those four reasons is tested against a small working example, alongside what React already ships for that case and the situation where a state library genuinely pays for itself.

Four common justifications

The arguments usually arrive in a predictable order:

  • Passing a value through components that do not use it is painful, so the app needs a store.
  • Client state that actually changes, like a light or dark theme flag, surely needs a library.
  • Getting every other component that reads that state to update automatically, without wiring props by hand, must require one.
  • If a full library is too much, Context is the safe middle ground.

Each one falls apart once you look at concrete code.

Prop drilling: a problem React closed years ago

Prop drilling means handing a value down through several layers purely so a deeply nested component can read it, while none of the intermediate layers use it. Four small files show the shape. App owns a user object in state and passes it to Dashboard.

// App.jsx
import { useState } from 'react';
import Dashboard from './components/Dashboard';

function App() {
  const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
  return <Dashboard user={user} />;
}
export default App;

Dashboard does nothing with user except forward it to Sidebar.

// components/Dashboard.jsx
import Sidebar from './Sidebar';

function Dashboard({ user }) {
  return <Sidebar user={user} />;
}
export default Dashboard;

Sidebar does the same, unpacking two fields for Avatar.

// components/Sidebar.jsx
import Avatar from './Avatar';

function Sidebar({ user }) {
  return <Avatar name={user.name} avatarUrl={user.avatarUrl} />;
}
export default Sidebar;

Only Avatar actually renders the data.

// components/Avatar.jsx
function Avatar({ name, avatarUrl }) {
  return <img src={avatarUrl} alt={name} />;
}

export default Avatar;

Neither Dashboard.jsx nor Sidebar.jsx reads user for its own logic. They are couriers. If avatarUrl is renamed to photoUrl, both files need edits even though their behavior did not change, simply because they carry something they do not own.

Context delivers the value directly

React's built-in answer is Context. First, a context object is created once in its own module.

// context/UserContext.js
import { createContext } from 'react';

const UserContext = createContext(null);
export default UserContext;

App then wraps the subtree in the context's Provider and passes user as its value, instead of passing it as a prop.

// App.jsx
import { useState } from 'react';
import UserContext from './context/UserContext';
import Dashboard from './components/Dashboard';

function App() {
  const [user, setUser] = useState({ name: 'Akshat', avatarUrl: '/me.png' });
  return (
    <UserContext.Provider value={user}>
      <Dashboard />
    </UserContext.Provider>
  );
}
export default App;

Dashboard and Sidebar shrink to pure layout, with no mention of user at all.

// components/Dashboard.jsx
import Sidebar from './Sidebar';

function Dashboard() {
  return <Sidebar />;
}
export default Dashboard;
// components/Sidebar.jsx
import Avatar from './Avatar';

function Sidebar() {
  return <Avatar />;
}
export default Sidebar;

Finally, Avatar reads the value itself.

// components/Avatar.jsx
import { useContext } from 'react';
import UserContext from '../context/UserContext';

function Avatar() {
  const user = useContext(UserContext);
  return <img src={user.avatarUrl} alt={user.name} />;
}
export default Avatar;

The three pieces each have one job. createContext builds a channel that is independent of props. The Provider makes user available to its entire subtree at once. useContext(UserContext) reads the value from the nearest matching Provider above the component, skipping every layer in between. The intermediate components stop mentioning user because they never needed it. As a side note, React 19 also lets you render the context object directly as a provider, but the .Provider form shown here still works.

Why the prop drilling argument outlived its reason

The history explains why the argument persists. Redux appeared in June 2015, created by Dan Abramov and Andrew Clark and based on Facebook's Flux architecture: a single central store with strict rules for how state may change. React's Context API only became a stable, officially supported feature in version 16.3, in March 2018, almost three years later. During Redux's early rise, a store really was the practical way to make a value reachable anywhere in the tree without threading it through every component.

That stopped being true once Context became stable, but the teaching never caught up. Tutorials kept using prop drilling as the motivating pain because it is easy to draw on a whiteboard, and the habit survived long after the reason for it disappeared.

Prop drilling, though, is about distribution, not change. The next question is what happens when the distributed value starts changing on the client.

Changing state is exactly what useState does

Take a theme toggle: a single flag holding light or dark, flipped by a button that sits right next to the text showing it.

// components/SettingsPanel.jsx
import { useState } from 'react';

function SettingsPanel() {
  const [theme, setTheme] = useState('light');
  return (
    <div>
      <p>Current theme: {theme}</p>
      <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
        Toggle Theme
      </button>
    </div>
  );
}
export default SettingsPanel;

Clicking the button calls setTheme, SettingsPanel re-renders with the new value, and the paragraph updates. No library is involved, and none is needed. Owning a value, changing it, and re-rendering the owner is precisely what useState exists for, and every React app does it constantly.

The example usually gets presented with a twist that does all the real work. The difficulty is not that the value changes; it is that the value is read somewhere else. Imagine the button remains inside SettingsPanel.jsx while Header.jsx and Sidebar.jsx, two unrelated files that neither import nor are imported by SettingsPanel, must also show the current theme. State created with useState belongs to a single component instance. Nothing outside that component can read it or be notified of changes unless a shared ancestor passes it down, which brings back prop drilling.

So useState handles change perfectly well. The open question is how two components with no common parent update automatically without props.

Sharing state across unrelated components with useSyncExternalStore

If neither Header nor Sidebar can own the value, it has to live outside the component tree: in a module-level variable that is initialized a single time, when its file is first imported, instead of inside some component function. React never sees a plain variable being reassigned, so it cannot re-render anything on its own when that value moves. A small store module fills that gap.

// store/themeStore.js
import { useSyncExternalStore } from 'react';

let state = { theme: 'light' };
const listeners = new Set();
export function setTheme(theme) {
  state = { ...state, theme };
  listeners.forEach((listener) => listener());
}
function subscribe(listener) {
  listeners.add(listener);
  return () => listeners.delete(listener);
}
export function useTheme() {
  return useSyncExternalStore(subscribe, () => state.theme);
}

The module holds a state object and a Set of listeners. setTheme replaces state with a new object and calls every listener. A private registration function adds a listener to the set and hands back a cleanup function that removes it. useTheme connects all of this to React through useSyncExternalStore, a hook designed specifically for state that lives outside components and is read by several of them.

The hook takes two arguments here. The first, that registration function, tells React how to attach a callback that should fire whenever the external value changes, and it must return a matching unsubscribe function for cleanup. The second, getSnapshot, here the arrow function () => state.theme, is how React fetches the present value whenever it needs it.

Two consumers import the hook, and neither knows about the other.

// components/Header.jsx
import { useTheme } from '../store/themeStore';

function Header() {
  const theme = useTheme();
  return <header className={theme === 'dark' ? 'header-dark' : 'header-light'}>Site Header</header>;
}
export default Header;
// components/Sidebar.jsx
import { useTheme } from '../store/themeStore';

function Sidebar() {
  const theme = useTheme();
  return <aside className={theme === 'dark' ? 'sidebar-dark' : 'sidebar-light'}>Navigation</aside>;
}
export default Sidebar;

A third component holds the button and imports both the hook and setTheme from the same store.

// components/ThemeToggleButton.jsx
import { setTheme, useTheme } from '../store/themeStore';

function ThemeToggleButton() {
  const theme = useTheme();
  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      Toggle Theme
    </button>
  );
}
export default ThemeToggleButton;

What happens, step by step

Every step below is observable if you run the code:

  • On mount, Header and Sidebar each call useTheme(). React calls getSnapshot for both, gets 'light', and renders them with it.
  • React also invokes the registration function once per component, so two internal React callbacks are added to the shared listeners set. The components remain independent entries in that set.
  • A click in ThemeToggleButton calls setTheme('dark'). First, state is reassigned to a brand-new object, { theme: 'dark' }; this is plain JavaScript with nothing React-specific about it.
  • Second, setTheme loops over the set and invokes each listener. Since those listeners belong to React, invoking them prompts React to look at every component that registered.
  • React calls getSnapshot again for Header, sees 'dark' instead of 'light', and re-renders it. The same check re-renders Sidebar.

This setTheme is not the setter returned by useState. It is ordinary hand-written code that does two jobs in one call: update the source of truth, then notify whoever is listening.

No props were passed anywhere. The entire wiring consists of imports. This is also, roughly, what Zustand does internally: a small, packaged version of the same register-and-notify pattern.

Caveats worth knowing

  • getSnapshot must return the same value when nothing has changed. Returning a primitive like state.theme is safe; building a new object or array on every call makes React think the store changed constantly.
  • If you render on the server, useSyncExternalStore accepts a third argument, getServerSnapshot, for the initial HTML. Module-level state on a server is also shared across requests, so keep per-user data out of it.

Why Context is not the safe middle ground

With a module store, there is nothing to provide. state in themeStore.js never lived in the component tree; components get at it through a hook call, with no ancestor Provider involved. Wrapping App in a ThemeProvider would add a layer that hands down nothing.

Context has legitimate uses of its own: scoping a value to one subtree, or swapping a dependency in tests by rendering a different Provider. It is often recommended as the cautious alternative to a library, though, and in that role it costs more than it seems. Consider a single context holding both a theme and a cart.

// context/AppContext.jsx
import { createContext, useState } from 'react';

const AppContext = createContext();
export function AppProvider({ children }) {
  const [state, setState] = useState({
    theme: 'light',
    cart: ['book'],
  });
  return <AppContext.Provider value={state}>{children}</AppContext.Provider>;
}
export default AppContext;

A small label reads just the theme from it.

// components/ThemeLabel.jsx
import { useContext } from 'react';
import AppContext from '../context/AppContext';

function ThemeLabel() {
  const { theme } = useContext(AppContext);
  return <span>{theme}</span>;
}

export default ThemeLabel;

useContext(AppContext) subscribes ThemeLabel to the whole object the Provider holds, not to theme alone. When cart changes elsewhere, the Provider receives a fresh state object, and because the reference differs, ThemeLabel renders again as well, even though theme did not change and the component never touches cart. Context has no built-in way to say "wake me only when this field changes"; every consumer wakes on every change to the value.

The store from the previous section already avoids this. useTheme() reads a single slice through its getSnapshot, and a component re-renders only when that slice's returned value differs. Using Context as the middle ground saves an install but hands you a larger re-render bill. You can soften it by splitting state into several narrow contexts, but at that point you are hand-building what a selector-based store gives you for free.

Where state libraries actually earn their place

None of this makes Redux, Zustand or MobX pointless. The four justifications failed; the libraries did not. Prop drilling is solved by Context. Changing state is solved by useState. Automatic updates in unrelated components are solved by useSyncExternalStore, which ships with React. And Context, the supposed compromise, turns out to be more expensive than a tiny store for shared, frequently changing values.

The real case for a library appears when shared state has many independent writers rather than mostly readers, and when the count of consuming components grows beyond what a handful of hand-rolled module stores can keep consistent. Coordinating updates, middleware, devtools, and predictable change tracking at that scale is where a mature library pays for itself, and it deserves its own walkthrough.

Key takeaways

  • Reach for Context, not a store, when the only problem is passing values through layers that do not use them.
  • useState is the right tool for changing state owned by one component.
  • For state shared by components with no common parent, a small store built on useSyncExternalStore is often enough.
  • A single broad Context re-renders every consumer on any change; prefer narrow contexts or a selector-based store for frequently changing data.
  • Adopt a state library when you have many independent writers and a growing set of readers, not because of prop drilling.