State Lifting in React: Best Practices to Avoid Prop Drilling

State lifting in React works best when paired with the Context API, custom hooks, and strategic provider placement to eliminate excessive prop threading through intermediate components.

State lifting is the pattern of moving shared state to the nearest common ancestor so multiple components can synchronize their data. While this solves coordination problems, passing state and setters through many intermediate layers creates prop drilling, which fragments your component hierarchy and makes maintenance difficult. The facebook/react repository provides several core APIs—specifically the Context system and Hooks—that enable you to lift state without sacrificing code clarity.

Understanding State Lifting and Prop Drilling

When two or more components need to share the same changing data, React recommends lifting that state up to their closest common ancestor. The parent then passes the state down via props, and callbacks to update it. However, when the consumer components are deeply nested, every intermediate layer must forward props it does not use itself. This prop drilling creates tight coupling between layers and makes refactoring hazardous because changing a prop name requires updates across many files.

Best Practices to Avoid Prop Drilling When Lifting State

Use the Context API for Global-ish State

Context is React’s official escape hatch for prop drilling. It allows you to expose a value to any descendant without threading props through intermediate components. According to the source code in packages/react/src/ReactContext.js at line 14, React.createContext creates a context object containing Provider and Consumer properties. To consume the context, useContext is implemented in packages/react/src/ReactHooks.js between lines 53-63, allowing functional components to subscribe to context changes.

Create a context at the module level, provide the lifted state at the common ancestor using <MyContext.Provider value={...}>, and consume it anywhere with const value = React.useContext(MyContext).

Encapsulate Logic in Custom Hooks

A custom hook bundles the state value, its updater, and any derived logic into a reusable function. By exporting a hook like useCart that internally manages state, components can import and use the hook directly without receiving props from a parent. This pattern keeps the component tree shallow and colocates related logic.

Define the hook once (e.g., function useCart() { const [items, setItems] = React.useState([]); ... return {items, addItem, removeItem}; }), then import it directly in each component that needs the feature.

Split Container and Presentational Components

Container components own the lifted state and business logic, while presentational components receive only the data they render. This separation reduces the depth of prop passing because containers sit high in the tree and pass only necessary slices of state to leaf UI components. If many grandchildren need the same data, the container can be wrapped in a context provider rather than passing props through every level.

Memoize Context Values

If the context provider recreates the value object on every render, all consumers will re-render even when the relevant slice did not change. Memoization keeps renders cheap. Wrap the provided value with React.useMemo(() => ({state, setState}), [state]) before passing it to <MyContext.Provider>. This ensures reference stability and prevents cascading updates.

Split Contexts by Concern

A single context carrying many unrelated pieces forces every consumer to re-render when any piece changes. Splitting contexts yields finer-grained updates. Create separate contexts for independent concerns (e.g., AuthContext, ThemeContext). Each consumer subscribes only to the context it cares about, isolating re-renders to specific subtrees.

Use Reducer + Context for Complex State

When updates are numerous or dependent on previous state, a reducer centralizes transition logic and makes intent explicit. Combine React.useReducer with a context provider (<StateProvider>). Dispatch actions from any descendant via useContext(StateContext).dispatch. This pattern mirrors Redux but stays within React’s core API, as documented in the repository’s hook implementations.

Keep Providers Close to Consumers

Placing the provider too high forces unnecessary re-renders of large sub-trees; placing it too low defeats the purpose of sharing. Identify the smallest common ancestor of all components that actually need the state and put the provider there. This minimizes the prop-drilling surface while still avoiding deep prop passing. The React DevTools (documented in packages/react-devtools/README.md) visualize provider boundaries, helping you audit placement.

Implementation Examples

Simple State Lifting with Context

This example demonstrates lifting state to a provider and consuming it in deeply nested buttons without prop drilling.

// CounterContext.tsx
import * as React from 'react';

// Create the context (source: ReactContext.js)
export const CounterContext = React.createContext<{
  count: number;
  setCount: React.Dispatch<React.SetStateAction<number>>;
} | null>(null);

// Provider that lifts the state
export const CounterProvider: React.FC<{children: React.ReactNode}> = ({
  children,
}) => {
  const [count, setCount] = React.useState(0);

  // Memoize the value (prevents unnecessary renders)
  const value = React.useMemo(() => ({count, setCount}), [count]);

  return (
    <CounterContext.Provider value={value}>
      {children}
    </CounterContext.Provider>
  );
};
// IncrementButton.tsx
import * as React from 'react';
import {CounterContext} from './CounterContext';

export const IncrementButton = () => {
  // Consume the lifted state (useContext source in ReactHooks.js)
  const ctx = React.useContext(CounterContext);
  if (!ctx) throw new Error('IncrementButton must be used within CounterProvider');

  return (
    <button onClick={() => ctx.setCount(c => c + 1)}>
      Increment (current: {ctx.count})
    </button>
  );
};
// App.tsx
import * as React from 'react';
import {CounterProvider} from './CounterContext';
import {IncrementButton} from './IncrementButton';
import {Display} from './Display';

export const App = () => (
  <CounterProvider>
    <Display />          {/* reads the count */}
    <IncrementButton />  {/* updates the count */}
  </CounterProvider>
);

Custom Hook + Context (Reducer Pattern)

For more complex state, combine a reducer with context to centralize logic and avoid prop drilling.

// useCart.ts
import * as React from 'react';

type CartItem = {id: string; qty: number};
type Action =
  | {type: 'add'; item: CartItem}
  | {type: 'remove'; id: string};

function cartReducer(state: CartItem[], action: Action): CartItem[] {
  switch (action.type) {
    case 'add':
      return [...state, action.item];
    case 'remove':
      return state.filter(i => i.id !== action.id);
    default:
      return state;
  }
}

// Hook that encapsulates state & logic
export function useCart() {
  const [items, dispatch] = React.useReducer(cartReducer, []);
  const addItem = (item: CartItem) => dispatch({type: 'add', item});
  const removeItem = (id: string) => dispatch({type: 'remove', id});
  return {items, addItem, removeItem};
}
// CartContext.tsx
import * as React from 'react';
import {useCart} from './useCart';

export const CartContext = React.createContext<ReturnType<typeof useCart> | null>(
  null,
);

export const CartProvider: React.FC<{children: React.ReactNode}> = ({
  children,
}) => {
  const cart = useCart();
  return (
    <CartContext.Provider value={cart}>{children}</CartContext.Provider>
  );
};
// CartList.tsx
import * as React from 'react';
import {CartContext} from './CartContext';

export const CartList = () => {
  const ctx = React.useContext(CartContext);
  if (!ctx) throw new Error('CartList must be used within CartProvider');

  return (
    <ul>
      {ctx.items.map(item => (
        <li key={item.id}>
          {item.id} – {item.qty}
          <button onClick={() => ctx.removeItem(item.id)}>✕</button>
        </li>
      ))}
    </ul>
  );
};
// App.tsx
import * as React from 'react';
import {CartProvider} from './CartContext';
import {CartList} from './CartList';
import {AddItemButton} from './AddItemButton';

export const App = () => (
  <CartProvider>
    <AddItemButton />
    <CartList />
  </CartProvider>
);

Key Source Files in the React Repository

The following files from the facebook/react repository provide the concrete implementation details for the patterns discussed above:

File What it shows Link
packages/react/src/ReactHooks.js Implementation of useContext (the hook that reads from a context) at lines 53-63 ReactHooks.js
packages/react/src/ReactContext.js Definition of createContext and the internal context object shape at line 14 ReactContext.js
packages/react/README.md High-level overview of the React public API, including guidance on when to use Context React README
packages/react-devtools/README.md Visualization of context providers, reinforcing why proper provider placement matters DevTools README
packages/eslint-plugin-react-hooks/README.md The "rules of hooks", reminding developers to keep hooks (including useContext) at the top level ESLint Hooks README

Summary

  • State lifting in React moves shared data to the nearest common ancestor, but without proper architecture, this leads to prop drilling through intermediate layers.
  • The Context API (React.createContext in packages/react/src/ReactContext.js and useContext in packages/react/src/ReactHooks.js) eliminates manual prop threading by exposing values directly to descendants.
  • Custom hooks encapsulate state logic and can be imported directly by any component, flattening the dependency tree.
  • Memoizing context values with useMemo prevents unnecessary re-renders when the provider updates, while splitting contexts by concern isolates update scopes.
  • For complex state, combining reducers with context centralizes transition logic and avoids passing callbacks through multiple layers.

Frequently Asked Questions

What is prop drilling and why is it problematic?

Prop drilling occurs when you pass data through multiple intermediate component layers that do not use the data themselves, only forwarding it to children. This creates tight coupling between unrelated parts of your tree, makes refactoring difficult because changing a prop name requires updates across many files, and obscures which components actually depend on the data.

When should I use Context instead of lifting state?

Use Context when the state needs to be accessed by many components at different nesting levels, or when you find yourself passing the same prop through many intermediate layers that do not use it. According to the React source in packages/react/src/ReactContext.js, Context is designed specifically to share values between components without explicitly passing props through every level of the tree.

How do I prevent unnecessary re-renders when using Context?

Wrap the context value in React.useMemo before passing it to the Provider, ensuring the object reference remains stable unless the underlying state changes. Additionally, split your context into multiple focused contexts (e.g., ThemeContext and UserContext) rather than one large context object, so consumers only re-render when the specific slice they subscribe to updates.

Can I use custom hooks without Context to avoid prop drilling?

Yes, custom hooks can reduce prop drilling by encapsulating stateful logic that components import directly. However, hooks alone do not share state between components—each component calling useState in a custom hook gets its own isolated state. To share state across components without prop drilling, you must combine custom hooks with Context, where the hook consumes the context internally and exposes a clean API to the component.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →