Skip to main content
Madhukar
All Articles

State Management in React: Prop Drilling, Context API, React.memo, useMemo, and useCallback

July 18, 20269 min read
PerformanceFrontendWeb DevelopmentReactJavaScript
State Management in React: Prop Drilling, Context API, React.memo, useMemo, and useCallback

Why does managing state become difficult as applications grow?

A brand-new React app usually starts simple: one component, a piece of state, done. But add ten more components, three levels of nesting, and a login system — and suddenly a single piece of state needs to reach a component buried five layers deep, while unrelated parts of the UI keep re-rendering for no obvious reason.

Nothing about React itself changed. What changed is the shape of the tree the data has to travel through. This guide walks through that problem layer by layer — from prop drilling, to the Context API, to React’s rendering model, to the three optimization tools (React.memo, useMemo, useCallback) that only make sense once you understand why components re-render in the first place.

1. Why State Management Becomes Difficult

Growing component trees

Every React app is a tree. Small trees are easy to reason about — state lives near where it’s used. As the tree grows wider and deeper, the distance between “where state lives” and “where state is needed” grows too.

Sharing data across components

The same piece of data — a logged-in user, a selected theme, a shopping cart — is often needed in components that live in completely different branches of the tree, not just a parent and its direct child.

Repeated state passing

To get data from a top-level component to a deeply nested one, it has to be passed down as a prop through every component in between — even components that don’t use that data themselves, and only exist to forward it along.

Performance concerns

Every state update in React can trigger re-renders in the components connected to it. As trees grow, an update meant for one small part of the UI can unintentionally ripple through far more components than it needs to.

Application scalability challenges

None of this is a “bug” in React — it’s a natural consequence of a tree-shaped UI meeting a growing application. The tools in this guide exist specifically to keep that growth manageable.

2. Understanding Prop Drilling

What prop drilling is

Prop drilling is passing a prop through multiple layers of components solely so it can reach a deeply nested component — even though the intermediate components never actually use that prop themselves.

function App() {
const [user, setUser] = useState({ name: "Aarav" });
return <Layout user={user} />;
}

function Layout({ user }) {
return <Sidebar user={user} />; // Layout doesn't use `user` — just forwards it
}

function Sidebar({ user }) {
return <UserAvatar user={user} />; // Sidebar doesn't use it either
}

function UserAvatar({ user }) {
return <img src={user.avatar} alt={user.name} />; // finally used here
}

Why prop drilling happens

React’s data flow is intentionally one-directional — data flows down via props, not sideways or automatically upward. That predictability is a real strength, but it means there’s no built-in shortcut for “skip five levels and hand this straight to the component that needs it.”

Problems caused by deep prop chains

  • Noise — every intermediate component’s signature is cluttered with props it doesn’t actually use
  • Fragile refactors — renaming or restructuring a prop means updating every component along the chain, not just the one that needs it
  • Unclear ownership — it becomes hard to tell, at a glance, which component the data actually belongs to versus which ones are just passing it through

Maintainability concerns

The deeper the chain, the more this compounds. A team of developers working across different parts of a large tree can end up afraid to touch a shared prop, simply because they can’t easily see everywhere it’s threaded through.

Real-world examples

Authentication state (the logged-in user) needed in a header, a settings page, and a comment box; a selected theme needed in a navbar, a card component, and a modal — all classic candidates for prop drilling if passed manually down the tree.

3. The Context API

Why Context API was introduced

React’s built-in Context API exists to solve exactly this problem: sharing data across a component tree without manually threading it through every intermediate component as props.

Creating shared state

Context is created once, typically in its own file, and wraps whichever part of the tree needs access to the shared value:

const UserContext = createContext(null);

Provider and Consumer concepts

  • A Provider wraps a part of the component tree and supplies the actual value
  • Any component inside that Provider — no matter how deeply nested — can read the value directly, using the useContext hook
function App() {
const [user, setUser] = useState({ name: "Aarav" });
return (
<UserContext.Provider value={user}>
<Layout />
</UserContext.Provider>
);
}

function UserAvatar() {
const user = useContext(UserContext); // read directly — no props passed through Layout or Sidebar
return <img src={user.avatar} alt={user.name} />;
}

Accessing data without prop drilling

Layout and Sidebar no longer need to know user exists at all. They're no longer cluttered with props they never use — the data flows straight to the component that actually needs it.

Common use cases

Context works best for data that’s genuinely global to a section of the app — not for every piece of state in the tree.

4. When Context API Works Well

Authentication state

The logged-in user’s identity is needed across unrelated parts of the app — a header avatar, a checkout page, a settings form — making it a textbook Context use case.

Theme management

Light/dark mode or brand theming typically needs to reach nearly every visual component, with infrequent changes — ideal for Context.

User preferences

Language selection, display settings, and similar app-wide preferences fit the same pattern: read in many places, changed rarely.

Global application settings

Feature flags, locale data, or app-wide configuration loaded once at startup and read throughout the tree.

Small to medium applications

For smaller applications, Context alone is often entirely sufficient — reaching for a full external state management library before you actually need one adds complexity without a matching benefit.

Where Context works less well: state that changes very frequently (like a text input’s live value on every keystroke) or is only needed by a couple of nearby components — plain props are simpler and avoid the re-render tradeoffs covered next.

5. Understanding React Re-renders

Component render behavior

A React component re-renders when its state changes, when its props change, or when its parent re-renders — the third case surprises a lot of developers.

Parent-child render relationships

By default, when a parent component re-renders, every child in its render tree re-renders too — regardless of whether that specific child’s own props actually changed.

Why unnecessary re-renders occur

Imagine a search box and an unrelated sidebar, both children of the same parent. Typing in the search box updates state in the parent — and by default, the sidebar re-renders too, even though nothing it displays actually changed.

Performance implications

For small components, this is invisible — React is fast, and most re-renders cost almost nothing. It becomes a real, measurable problem specifically when a child component is expensive to render (heavy computation, large lists, complex charts) and re-renders far more often than its actual output changes.

This is the exact gap React.memo, useMemo, and useCallback are built to close — and only that gap. They're not general-purpose speed switches; they're targeted fixes for unnecessary, expensive re-renders.

6. React.memo

What React.memo does

React.memo wraps a component and tells React: "skip re-rendering this component if its props haven't actually changed" — even if its parent just re-rendered.

const ExpensiveList = React.memo(function ExpensiveList({ items }) {
// only re-renders when `items` actually changes
return items.map(item => <Row key={item.id} item={item} />);
});

Preventing unnecessary renders

Without React.memo, ExpensiveList would re-render every time its parent re-renders, regardless of whether items changed. With it, React compares the new props against the previous ones and skips the re-render if they're the same.

Component memoization

React.memo performs a shallow comparison of props by default — comparing each prop with ===. This matters a lot for object and function props, covered in the useMemo/useCallback sections below.

When React.memo helps

  • The component is genuinely expensive to render (large lists, complex UI, heavy calculations inside it)
  • The component re-renders often due to a parent, while its own props rarely change

When React.memo hurts

  • On cheap, simple components, the comparison check itself can cost more than just re-rendering would have
  • If the component receives new object/array/function props on every render (a very common mistake), the shallow comparison always finds them “different,” and React.memo does nothing useful — this is exactly where useMemo and useCallback come in.

7. useMemo

What useMemo does

useMemo caches the result of a calculation between renders, only recomputing it when its listed dependencies actually change.

const sortedItems = useMemo(() => {
return items.slice().sort((a, b) => a.price - b.price);
}, [items]);

Memoizing expensive calculations

Without useMemo, that sort would re-run on every render of the component — even ones triggered by something completely unrelated, like a sibling's state update.

Avoiding repeated computation

useMemo is most valuable for computation that's genuinely costly — sorting or filtering large datasets, complex derived calculations, or anything with real, measurable CPU cost.

Common use cases

  • Sorting or filtering large lists
  • Expensive derived values (aggregations, transformations)
  • Creating a stable object reference to pass as a prop to a React.memo-wrapped child

Performance tradeoffs

useMemo itself isn't free — it uses memory to store the cached value and adds a small comparison cost on every render. For cheap calculations (adding two numbers, formatting a short string), the overhead of useMemo can outweigh what it saves. It's a targeted tool for measured bottlenecks, not a default wrapper for every value.

8. useCallback

What useCallback does

useCallback is useMemo's sibling, specifically for functions. It returns the same function reference between renders, as long as its dependencies haven't changed.

const handleClick = useCallback(() => {
setCount(c => c + 1);
}, []);

Function memoization

Without useCallback, a component creates a brand-new function on every single render — even if that function does exactly the same thing each time. That new reference is the exact problem React.memo's shallow comparison can't see past.

Preventing unnecessary child renders

// Without useCallback: onSave is a new function every render,
// so MemoizedForm re-renders every time, defeating React.memo entirely
<MemoizedForm onSave={() => save(data)} />

// With useCallback: onSave keeps the same reference across renders,
// so MemoizedForm can actually skip re-rendering when nothing else changed
const onSave = useCallback(() => save(data), [data]);
<MemoizedForm onSave={onSave} />

useCallback vs useMemo

The distinction is simple once stated plainly: useMemo caches a value; useCallback caches a function. In fact, useCallback(fn, deps) is functionally equivalent to useMemo(() => fn, deps) — they're the same underlying mechanism, just shaped for two different use cases.

Common patterns

  • Memoizing event handlers passed down to React.memo-wrapped children
  • Stabilizing functions passed into a useEffect dependency array, to avoid the effect re-running unnecessarily
  • Passing callbacks into custom hooks that themselves rely on stable references

9. Choosing the Right Optimization Strategy

When to use Context API

Reach for Context when data is genuinely needed across unrelated, distant parts of the tree and changes relatively infrequently — authentication, theming, locale, global settings.

When to use React.memo

Reach for it when a specific component is expensive to render and re-renders often due to its parent, while its own relevant props rarely change.

When to use useMemo

Reach for it when you have a measurably expensive calculation being repeated unnecessarily on every render — not for trivial arithmetic or short-string formatting.

When to use useCallback

Reach for it specifically when a function is being passed as a prop to a React.memo-wrapped child, or into a useEffect dependency array where reference stability actually matters.

Avoiding premature optimization

All three optimization tools add code complexity and small overhead of their own. Wrapping everything in React.memo/useMemo/useCallback "just in case" often makes code harder to read without any measurable benefit. The right sequence is: build it simply first, measure if there's an actual rendering bottleneck (React DevTools Profiler is the standard tool for this), and then apply the specific optimization that addresses what you measured.

10. Scaling React Applications

Component architecture

Well-scaled React apps keep components focused and keep the tree’s shape intentional — state doesn’t drift upward “just in case” it’s needed elsewhere; it stays as close as possible to where it’s actually used.

State ownership

A clear rule of thumb: state should live in the lowest common ancestor of every component that needs it — no higher, no lower. Lifting state too far up causes unnecessary re-renders across unrelated branches; keeping it too local causes duplication and sync bugs.

Shared state strategies

For truly global data, Context is often enough. For state that’s shared but not truly global — say, shared only within one feature’s subtree — a more local Context, or simply better component composition, is usually a better fit than reaching for a global store.

Performance considerations

Performance problems in React are, more often than not, architectural rather than technical — a component tree shaped so that unrelated UI keeps getting dragged into re-renders it doesn’t need. React.memo, useMemo, and useCallback are precise tools for specific symptoms; they don't fix a poorly shaped tree, they just patch around it.

Building maintainable applications

The healthiest long-term signal isn’t “how many hooks did we use” — it’s whether a new developer can look at the tree and immediately tell where a given piece of state lives, and why. Prop drilling, Context, and memoization are all just different answers to that same underlying question: where should this data live, and how should it get to where it’s needed?

Final Takeaway

Prop drilling isn’t a mistake to be avoided at all costs — it’s the default, and it’s often perfectly fine for shallow trees. Context exists for when that default breaks down across distance. Re-renders aren’t bugs — they’re React’s default behavior, and React.memo, useMemo, and useCallback exist to selectively override that default only where it's measurably expensive. Used everywhere, they add noise. Used deliberately, in response to a real bottleneck, they're precise and effective. The through-line across all of it: know where your state lives, know why, and add complexity only when the tree has actually earned it.

Frequently Asked Questions

Does Context API replace Redux or other state management libraries?

> For many small to medium applications, yes. Larger applications with complex, frequently-changing global state sometimes still benefit from a dedicated state library’s tooling (like time-travel debugging or fine-grained subscription control) — but Context is often enough, and adding a library “by default” is a common case of premature complexity.

Does Context API cause performance problems?

> It can — every component consuming a Context re-renders whenever that Context’s value changes, even if only part of the value is relevant to a given consumer. Splitting a large Context into smaller, more focused ones is the usual fix.

Should I wrap every component in React.memo just to be safe?

> No. For cheap components, the comparison overhead usually costs more than it saves. Apply it where you’ve identified real, expensive, unnecessary re-renders — not as a blanket default.

What’s the simplest way to know if I actually need useMemo or useCallback?

> Measure first, with the React DevTools Profiler. If a component isn’t re-rendering excessively, or its calculations aren’t measurably slow, these hooks are solving a problem you don’t have yet.

Originally published by Mr Madhukar

Read the complete article on Medium with full formatting & reader responses.