Skip to main content
Frontend for Backend Engineers

State, Properly

Ravinder··6 min read
FrontendReactTypeScriptState ManagementTanStack Query
Share:
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

graph TD S[All Application State] S --> L[Local UI State] S --> SV[Server State] S --> U[URL State] L --> |"useState / useReducer"| LC["Ephemeral, component-scoped\nModal open, accordion expanded\nForm intermediate values"] SV --> |"TanStack Query / SWR"| SC["Remote data with cache\nUser profile, feed items\nMutation results"] U --> |"URL params / search params"| UC["Shareable, bookmarkable\nFilters, pagination, selected tab\nSearch query"]

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

flowchart TD Q1{Does this state come\nfrom a remote server?} Q1 -->|Yes| SV[Server state\nTanStack Query / SWR] Q1 -->|No| Q2{Should this state\nsurvive a page refresh\nor be shareable?} Q2 -->|Yes| URL[URL state\nuseSearchParams / useParams] Q2 -->|No| Q3{Is this state needed\nby multiple unrelated\ncomponents?} Q3 -->|Yes| CTX[Shared state\nuseContext / Zustand / Jotai] Q3 -->|No| LOCAL[Local state\nuseState / useReducer]

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/useReducer when 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.
Share: