State, Properly
Most state bugs are not logic bugs. They are categorization bugs — state placed in the wrong location, causing unnecessary re-renders, stale data, or UI that breaks on refresh. Backend engineers intuitively understand state locality (L1 cache vs database vs message queue), and that intuition maps directly to frontend state categories.
There are three distinct categories of frontend state. Knowing which to reach for eliminates entire classes of bugs before you write a line of logic.
The Three Categories
Local State: Ephemeral, Component-Scoped
Local state belongs in useState when it is:
- Owned by a single component (or a small subtree).
- Ephemeral — losing it on unmount is acceptable.
- Not meaningful to serialize or share via URL.
function ImageGallery({ images }: { images: Image[] }) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const [isLightboxOpen, setIsLightboxOpen] = useState(false);
function openLightbox(index: number) {
setSelectedIndex(index);
setIsLightboxOpen(true);
}
return (
<>
<div className="grid">
{images.map((img, i) => (
<img key={img.id} src={img.thumbnail} onClick={() => openLightbox(i)} />
))}
</div>
{isLightboxOpen && selectedIndex !== null && (
<Lightbox
image={images[selectedIndex]}
onClose={() => setIsLightboxOpen(false)}
/>
)}
</>
);
}The lightbox open/closed state and the selected index live and die with this component. No other part of the application needs to know about them. That is the test: if no sibling or ancestor needs this state, keep it local.
Server State: Remote Data with Asynchronous Lifecycle
Server state is data that lives on a remote server and must be fetched, cached, synchronized, and potentially mutated. This is where most backend engineers reach for useState + useEffect and create a maintenance nightmare.
The problem with rolling your own:
// AVOID: manual server state — missing cache, dedup, background refresh
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(setUser)
.finally(() => setLoading(false));
}, [userId]);
// No cache. Every mount refetches. No deduplication. No background sync.
}Use TanStack Query instead. It handles caching, deduplication, background refetching, and error states with minimal boilerplate:
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
staleTime: 5 * 60 * 1000, // treat as fresh for 5 minutes
});
if (isLoading) return <Spinner />;
if (error) return <ErrorState />;
return <div>{user.name}</div>;
}
// Mutations invalidate the cache, triggering automatic refetch
function UpdateNameForm({ userId }: { userId: string }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (name: string) =>
fetch(`/api/users/${userId}`, {
method: "PATCH",
body: JSON.stringify({ name }),
}).then(r => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user", userId] });
},
});
return (
<button onClick={() => mutation.mutate("New Name")}>
{mutation.isPending ? "Saving…" : "Save"}
</button>
);
}The queryKey is your cache key — analogous to a Redis key. staleTime controls when a cached entry is considered outdated. invalidateQueries is your cache eviction call.
URL State: Shareable, Bookmarkable
URL state is state that should survive a page refresh, be shareable via link, or affect browser history. Think of filters, search queries, pagination, and selected tabs.
The mistake is putting this in useState — then users cannot bookmark filtered views or share links.
// Next.js App Router example (same concept applies to React Router)
"use client";
import { useSearchParams, usePathname, useRouter } from "next/navigation";
import { useCallback } from "react";
function ProductFilter() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const category = searchParams.get("category") ?? "all";
const page = Number(searchParams.get("page") ?? "1");
const setFilter = useCallback(
(key: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(key, value);
if (key !== "page") params.set("page", "1"); // reset page on filter change
router.push(`${pathname}?${params.toString()}`);
},
[searchParams, pathname, router]
);
return (
<div>
<select value={category} onChange={e => setFilter("category", e.target.value)}>
<option value="all">All</option>
<option value="electronics">Electronics</option>
<option value="books">Books</option>
</select>
<Pagination current={page} onChange={p => setFilter("page", String(p))} />
</div>
);
}Now ?category=electronics&page=2 is a shareable, bookmarkable link. The Back button works correctly. The state round-trips through the URL, not through a component lifecycle.
Decision Flowchart
Lifting State and the Context Escape Hatch
When two sibling components need the same local state, lift it to the nearest common ancestor. When the common ancestor is far up the tree (prop drilling through 4+ levels), introduce Context or a lightweight store like Jotai or Zustand.
// Context for truly global state: theme, auth user, locale
const AuthContext = createContext<User | null>(null);
function AuthProvider({ children }: { children: React.ReactNode }) {
const { data: user } = useQuery({ queryKey: ["me"], queryFn: fetchCurrentUser });
return <AuthContext.Provider value={user ?? null}>{children}</AuthContext.Provider>;
}
function useAuth() {
const ctx = useContext(AuthContext);
if (ctx === undefined) throw new Error("useAuth must be inside AuthProvider");
return ctx;
}Context is not a general-purpose state manager — it is a dependency injection mechanism. Every consumer re-renders when the context value changes, which makes it expensive for high-frequency updates (mouse position, scroll offset). For those, prefer a store with fine-grained subscriptions.
Key Takeaways
- State has three distinct categories — local, server, and URL — and placing state in the wrong category causes entire classes of bugs.
- Local state belongs in
useState/useReducerwhen it is ephemeral and component-scoped; the test is whether any sibling or ancestor needs it. - Server state should be managed by TanStack Query or SWR, not hand-rolled
useEffect— you get caching, deduplication, and background sync for free. - URL state is the correct home for anything that should survive a refresh or be shareable — filters, pagination, and selected tabs all qualify.
- Lift state to the nearest common ancestor to share between siblings; use Context or a lightweight store only when prop drilling exceeds 3–4 levels.
- Context is dependency injection, not a state manager — avoid it for high-frequency updates and prefer stores with fine-grained subscriptions.