Where Should This State Live?
April 25, 202616 min read0 views
Where Should This State Live?
The Problem
You have a piece of state. Where does it go?
useStatein the component?- Lift it to a parent?
- Put it in Context?
- Use a state manager like Zustand?
- Store it in the URL?
- Keep it on the server?
Wrong choice = prop drilling hell, unnecessary re-renders, or a global store that knows what every modal is doing.
The Decision Framework
Ask these questions in order:
1. Can this state be derived from other state?
└─ YES → Don't store it. Compute it.
2. Does only ONE component need this?
└─ YES → useState in that component
3. Do a parent and its children need this?
└─ YES → useState in the parent, pass down
4. Do siblings need to share this?
└─ YES → Lift to common ancestor
5. Is this state tied to a URL (filter, pagination, search)?
└─ YES → Store in URL (searchParams)
6. Is this server data that multiple components read?
└─ YES → React Query / SWR / Server Components
7. Does most of the app need this (auth, theme, locale)?
└─ YES → Context or lightweight store (Zustand)
The Solutions
1. Derived State — Don't Store It
// ❌ Storing derived state
const [items, setItems] = useState([]);
const [itemCount, setItemCount] = useState(0);
// Every time items changes, you have to remember to update itemCount
// ✅ Compute it
const [items, setItems] = useState([]);
const itemCount = items.length; // Always correct
2. Local State — useState
// Only this component cares about whether dropdown is open
function Dropdown() {
const [isOpen, setIsOpen] = useState(false);
return (
<div>
<button onClick={() => setIsOpen(!isOpen)}>Toggle</button>
{isOpen && <Menu />}
</div>
);
}
3. Shared Between Parent-Child — Lift Up
// Parent owns the state, children receive it
function ProductFilters() {
const [priceRange, setPriceRange] = useState([0, 100]);
return (
<>
<PriceSlider value={priceRange} onChange={setPriceRange} />
<AppliedFilters priceRange={priceRange} />
</>
);
}
4. URL State — searchParams
// ✅ Filters, pagination, search — use URL
// Users can bookmark, share, refresh without losing state
// app/products/page.tsx
function ProductsPage({ searchParams }: { searchParams: { q?: string, page?: string } }) {
const query = searchParams.q ?? '';
const page = parseInt(searchParams.page ?? '1');
const products = await searchProducts(query, page);
return (
<>
<SearchInput defaultValue={query} />
<ProductList products={products} />
<Pagination currentPage={page} />
</>
);
}
// Update URL instead of useState
'use client';
import { useRouter, useSearchParams } from 'next/navigation';
function SearchInput({ defaultValue }) {
const router = useRouter();
const searchParams = useSearchParams();
function handleSearch(value: string) {
const params = new URLSearchParams(searchParams);
params.set('q', value);
params.set('page', '1');
router.push(`?${params.toString()}`);
}
return <input defaultValue={defaultValue} onChange={(e) => handleSearch(e.target.value)} />;
}
5. Server Data — React Query / Server Components
// ❌ Fetching in useEffect, storing in useState
const [user, setUser] = useState(null);
useEffect(() => {
fetchUser().then(setUser);
}, []);
// ✅ Let the data layer manage it
// Server Component
async function UserProfile() {
const user = await getUser(); // Cached, deduped automatically
return <Profile user={user} />;
}
// Or with React Query (Client)
function UserProfile() {
const { data: user } = useQuery({ queryKey: ['user'], queryFn: getUser });
return <Profile user={user} />;
}
6. App-Wide State — Context or Zustand
// Theme, auth, locale — truly global
// Use Context for static-ish values, Zustand for frequently changing
// Zustand — simple, no Provider needed
import { create } from 'zustand';
const useAuthStore = create((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// Use anywhere
function Header() {
const user = useAuthStore((state) => state.user);
return <span>{user?.name}</span>;
}
function LoginButton() {
const login = useAuthStore((state) => state.login);
return <button onClick={() => login({ name: 'John' })}>Login</button>;
}
Quick Reference
| State Type | Where | Example |
|---|---|---|
| UI state (single component) | useState | Modal open, input value |
| UI state (few components) | Lift to parent | Accordion expanded items |
| URL-worthy state | searchParams | Filters, pagination, tabs |
| Server data | React Query / RSC | User, products, orders |
| Auth/theme/locale | Context or Zustand | Current user, dark mode |
| Complex forms | React Hook Form | Multi-step form state |
The Anti-Pattern
Don't put everything in global state:
// ❌ Global store for modal state
const useStore = create((set) => ({
isModalOpen: false,
modalContent: null,
openModal: (content) => set({ isModalOpen: true, modalContent: content }),
closeModal: () => set({ isModalOpen: false, modalContent: null }),
}));
// ✅ Modal state belongs in the component that owns the modal
function ProductCard({ product }) {
const [showDetails, setShowDetails] = useState(false);
return (
<>
<button onClick={() => setShowDetails(true)}>View</button>
{showDetails && <ProductModal product={product} onClose={() => setShowDetails(false)} />}
</>
);
}
TL;DR
- Derive what you can — don't store computed values
- Start local — useState in the component that owns it
- Lift only when needed — when siblings need to share
- URL for shareable state — filters, search, pagination
- Data layer for server state — React Query, SWR, or Server Components
- Global store sparingly — auth, theme, maybe cart
What did you think?