Steve Kinney

Lifting State Intelligently

“Lift state up” is one of React’s most repeated mantras—and for good reason. When multiple components need the same piece of data, moving it to their closest common ancestor makes perfect sense. But like most architectural advice, it’s easy to take this too far and accidentally create performance bottlenecks that ripple through your entire component tree.

The key insight? Lift state only when components genuinely need to share it. Everything else can (and should) stay local. When you do lift state, be intentional about where it lands and how changes propagate. A little upfront thinking can save you from debugging sluggish UIs later.

The Problem with Overzealous State Lifting

Let’s say you’re building a dashboard with multiple widgets. Each widget has its own loading state, data, and user interactions. The naive approach might be to lift all state to the top level—after all, centralized state feels organized, right?

// ❌ Over-lifting state creates unnecessary re-renders
function Dashboard() {
  const [widget1Loading, setWidget1Loading] = useState(false);
  const [widget1Data, setWidget1Data] = useState(null);
  const [widget2Loading, setWidget2Loading] = useState(false);
  const [widget2Data, setWidget2Data] = useState(null);
  const [widget3Loading, setWidget3Loading] = useState(false);
  const [widget3Data, setWidget3Data] = useState(null);

  return (
    <div>
      <Widget1
        loading={widget1Loading}
        data={widget1Data}
        setLoading={setWidget1Loading}
        setData={setWidget1Data}
      />
      <Widget2
        loading={widget2Loading}
        data={widget2Data}
        setLoading={setWidget2Loading}
        setData={setWidget2Data}
      />
      <Widget3
        loading={widget3Loading}
        data={widget3Data}
        setLoading={setWidget3Loading}
        setData={setWidget3Data}
      />
    </div>
  );
}

Here’s the problem: every time any widget loads data, the entire Dashboard component re-renders—along with every other widget—even though they have nothing to do with each other. You’ve created a performance chokepoint where isolated changes trigger unnecessary work across your entire tree.

Keep State Local by Default

The better approach? Start local, lift only when necessary. Each widget should manage its own state until you have a compelling reason to share it.

// ✅ Keep independent state local
function Dashboard() {
  return (
    <div>
      <Widget1 />
      <Widget2 />
      <Widget3 />
    </div>
  );
}

function Widget1() {
  const [loading, setLoading] = useState(false);
  const [data, setData] = useState(null);

  const loadData = async () => {
    setLoading(true);
    try {
      const result = await fetchWidget1Data();
      setData(result);
    } finally {
      setLoading(false);
    }
  };

  return (
    <div>
      {loading ? <Spinner /> : <WidgetContent data={data} />}
      <button onClick={loadData}>Refresh</button>
    </div>
  );
}

Now when Widget1 loads data, only Widget1 re-renders. The other widgets remain blissfully unaware, and your dashboard stays snappy.

This is React’s default behavior working in your favor. Components only re-render when their own state changes or their parent passes different props.

When to Actually Lift State

You should lift state when components need to:

  1. Share the same data and display it consistently
  2. Coordinate behavior (like master-detail views)
  3. Synchronize changes across multiple locations

Let’s look at a realistic example where lifting state makes sense:

// ✅ Lift state when components need to share data
function OrderManagement() {
  const [selectedOrderId, setSelectedOrderId] = useState<string | null>(null);
  const [orders, setOrders] = useState<Order[]>([]);

  return (
    <div className="flex">
      <OrderList
        orders={orders}
        selectedOrderId={selectedOrderId}
        onSelectOrder={setSelectedOrderId}
      />
      <OrderDetails
        orderId={selectedOrderId}
        onOrderUpdate={(updatedOrder) => {
          setOrders((prev) =>
            prev.map((order) => (order.id === updatedOrder.id ? updatedOrder : order)),
          );
        }}
      />
    </div>
  );
}

Here, OrderList and OrderDetails genuinely need to coordinate—selecting an order in the list should show its details, and updating an order should refresh the list. The shared state is justified.

Smart State Placement Strategies

Co-locate with the Components That Need It

Place state as close as possible to the components that use it. If only two sibling components need to share data, lift it to their immediate parent—not three levels up to some distant ancestor.

// ✅ Lift to the closest common parent
function ProductPage() {
  return (
    <div>
      <ProductHeader />
      <ProductDetails /> {/* This component manages its own state */}
      <RelatedProductsSection /> {/* This section has shared state */}
    </div>
  );
}

function RelatedProductsSection() {
  const [selectedCategory, setSelectedCategory] = useState('all');
  const [products, setProducts] = useState([]);

  return (
    <section>
      <CategoryFilter selected={selectedCategory} onChange={setSelectedCategory} />
      <ProductGrid products={products} category={selectedCategory} />
    </section>
  );
}

Use Multiple State Containers

Don’t feel compelled to cram everything into a single state object. Multiple focused pieces of state are often better than one monolithic object.

// ❌ Monolithic state object
function App() {
  const [appState, setAppState] = useState({
    user: null,
    currentPage: 'home',
    sidebarOpen: false,
    notifications: [],
    theme: 'light',
  });

  // Every state change re-renders the entire app
}

// ✅ Separate concerns
function App() {
  const [user, setUser] = useState(null);
  const [currentPage, setCurrentPage] = useState('home');

  return (
    <div>
      <Header user={user} />
      <Sidebar /> {/* Manages its own open/closed state */}
      <NotificationSystem /> {/* Manages its own notifications */}
      <ThemeProvider>
        {' '}
        {/* Manages theme separately */}
        <PageContent page={currentPage} />
      </ThemeProvider>
    </div>
  );
}

Contain State Changes with React.memo

When you do need to lift state but want to minimize re-render impact, use React.memo to create render boundaries:

// ✅ Memo prevents unnecessary re-renders of expensive components
const ExpensiveWidget = memo(function ExpensiveWidget({
  data,
  onAction,
}: {
  data: WidgetData;
  onAction: () => void;
}) {
  // This component only re-renders when data or onAction changes
  return <ComplexVisualization data={data} />;
});

function Dashboard() {
  const [sharedCounter, setSharedCounter] = useState(0);
  const [widgetData, setWidgetData] = useState(null);

  const handleAction = useCallback(() => {
    // Stable callback prevents ExpensiveWidget re-renders
    console.log('Action performed');
  }, []);

  return (
    <div>
      <button onClick={() => setSharedCounter((c) => c + 1)}>Count: {sharedCounter}</button>
      <ExpensiveWidget data={widgetData} onAction={handleAction} />
    </div>
  );
}

Real-World State Architecture

Here’s how you might structure state in a realistic application:

// ✅ Thoughtful state architecture
function App() {
  // Global app state - truly shared across the app
  const [user, setUser] = useState<User | null>(null);
  const [isOnline, setIsOnline] = useState(navigator.onLine);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      <Router>
        <NavBar />
        <main>
          <Routes>
            <Route path="/dashboard" element={<Dashboard />} />
            <Route path="/profile" element={<ProfilePage />} />
          </Routes>
        </main>
        <ConnectionStatus isOnline={isOnline} />
      </Router>
    </UserContext.Provider>
  );
}

function Dashboard() {
  // Page-level state - shared within this page
  const [selectedDateRange, setSelectedDateRange] = useState('week');

  return (
    <div>
      <DateRangePicker value={selectedDateRange} onChange={setSelectedDateRange} />
      <MetricsGrid dateRange={selectedDateRange} />
      <ActivityFeed /> {/* Manages its own state independently */}
    </div>
  );
}

const MetricsGrid = memo(function MetricsGrid({ dateRange }: { dateRange: string }) {
  // Component-level state - local to this component tree
  const [metrics, setMetrics] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    // Only fetch when dateRange changes
    loadMetrics(dateRange).then(setMetrics);
  }, [dateRange]);

  return (
    <div className="grid grid-cols-3 gap-4">
      {metrics.map((metric) => (
        <MetricCard key={metric.id} metric={metric} />
      ))}
    </div>
  );
});

Common Anti-Patterns to Avoid

The “Props Drilling Prevention” Trap

Just because you want to avoid props drilling doesn’t mean you should lift state to the top level. Consider Context or a state management library instead:

// ❌ Lifting state too high to avoid props drilling
function App() {
  const [theme, setTheme] = useState('light');

  return (
    <Layout theme={theme}>
      <Header theme={theme} />
      <Sidebar theme={theme} />
      <MainContent theme={theme}>
        <Dashboard theme={theme}>
          <Widget theme={theme} /> {/* This is getting ridiculous */}
        </Dashboard>
      </MainContent>
    </Layout>
  );
}

// ✅ Use Context for truly global concerns
const ThemeContext = createContext();

function App() {
  const [theme, setTheme] = useState('light');

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      <Layout>
        <Header />
        <Sidebar />
        <MainContent>
          <Dashboard>
            <Widget /> {/* Can access theme via useContext */}
          </Dashboard>
        </MainContent>
      </Layout>
    </ThemeContext.Provider>
  );
}

The “Future-Proofing” Fallacy

Don’t lift state “just in case” you might need it later. YAGNI (You Aren’t Gonna Need It) applies to state architecture too:

// ❌ Premature state lifting
function UserProfile() {
  const [userPreferences, setUserPreferences] = useState({});
  const [profileData, setProfileData] = useState({});
  const [accountSettings, setAccountSettings] = useState({});

  // Only one component uses each piece of state...

  return (
    <div>
      <PersonalInfo data={profileData} />
      <PreferencesPanel preferences={userPreferences} />
      <AccountSettings settings={accountSettings} />
    </div>
  );
}

// ✅ Let each section manage its own state until sharing is needed
function UserProfile() {
  return (
    <div>
      <PersonalInfo /> {/* Loads and manages its own data */}
      <PreferencesPanel /> {/* Loads and manages its own data */}
      <AccountSettings /> {/* Loads and manages its own data */}
    </div>
  );
}

Measuring the Impact

When you’re unsure whether to lift state, profile your app’s performance. React DevTools Profiler can show you which components are re-rendering and why:

  1. Before lifting: Profile your current implementation
  2. After lifting: Profile again and compare
  3. Look for: Unnecessary re-renders in unrelated components
  4. Optimize: Add memo boundaries or reconsider state placement
A few unnecessary re-renders aren’t always a problem—React is quite fast. Focus on the ones that cause noticeable lag or expensive computations.

Last modified on .