“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.
When to Actually Lift State
You should lift state when components need to:
- Share the same data and display it consistently
- Coordinate behavior (like master-detail views)
- 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:
- Before lifting: Profile your current implementation
- After lifting: Profile again and compare
- Look for: Unnecessary re-renders in unrelated components
- Optimize: Add
memoboundaries or reconsider state placement