On this page · sections
State, Props & State Ownership
Props flow down; state lives where it is owned. Choosing the right owner — and the right layer for server vs client data — prevents stale UI, prop chains, and duplicate truth.
| Concept | Direction / lifetime | Primary use |
|---|---|---|
| Props | Parent → child, read-only | Configuration and callbacks |
| Local state | Component-owned, ephemeral | UI toggles, form drafts |
| Lifted state | Shared ancestor | Siblings that must stay in sync |
| Derived state | Computed from other inputs | Filters, totals — not duplicated |
| Server state | Remote, async, goes stale | RSC fetch, React Query, SWR |
| Client state | Local, sync, dies with tab | Modal open, selection, Zustand slice |
Props vs State
Definition
Props are inputs from a parent — immutable from the child’s perspective. State is data the component owns and can update via setState.
'use client';
import { useState } from 'react';
function Counter({ initialCount }: { initialCount: number }) {
const [count, setCount] = useState(initialCount);
return (
<button type="button" onClick={() => setCount((c) => c + 1)}>
Count: {count}
</button>
);
}initialCount is a prop — the parent controls the starting value. count is state — only this component updates it after mount.
Single source of truth
One fact → one owner
Siblings need same value → lift state to nearest common ancestor
Value computable from props/state → derive in render, do not duplicate in stateInterview: Copying props into state on mount (
useState(prop)) creates a forked truth. Usekey={entityId}for entity switches or derive directly.
Lifting State
Definition
When two siblings must reflect the same data, move state to their shared parent and pass props down.
'use client';
import { useState } from 'react';
function FilterBar({
query,
onQueryChange,
}: {
query: string;
onQueryChange: (q: string) => void;
}) {
return (
<input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search…"
/>
);
}
function Results({ query }: { query: string }) {
const filtered = ITEMS.filter((item) =>
item.toLowerCase().includes(query.toLowerCase()),
);
return <ul>{filtered.map((item) => <li key={item}>{item}</li>)}</ul>;
}
const ITEMS = ['Apple', 'Banana', 'Cherry'];
function SearchPage() {
const [query, setQuery] = useState('');
return (
<>
<FilterBar query={query} onQueryChange={setQuery} />
<Results query={query} />
</>
);
}SearchPage owns query. Children receive read-only slices and callbacks — not a second copy.
Interview: Lifting state is about ownership, not performance. Re-renders follow data ownership in the tree.
Derived State
Definition
Derived state computes from server + client inputs — do not duplicate the server source into a second store.
'use client';
function OrderSummary({ orders, statusFilter }: {
orders: Order[];
statusFilter: 'open' | 'closed';
}) {
const visible = orders.filter((o) => o.status === statusFilter);
const total = visible.reduce((sum, o) => sum + o.amount, 0);
return <p>{visible.length} orders — ${total}</p>;
}Anti-pattern: fetch orders into React Query and copy them into Zustand without invalidation.
Compute in render → simple derivation
useMemo → expensive derivation with stable deps
Not useEffect → syncing props into state via effectInterview: “You Might Not Need an Effect” — if you can compute it during render, do not store it.
Prop Drilling
Definition
Prop drilling is passing props through components that never use them — only to reach a deep descendant. It is a maintainability smell, not a React performance bug.
AppShell → Sidebar → UserMenu (user + onLogout forwarded twice, consumed once)Shallow drilling through one layout wrapper is normal; six pass-through shells is a smell.
Interview: Re-renders follow data ownership, not prop depth. Drilling is not a performance defect — memo boundaries and narrower subscriptions fix perf.
Composition vs Context
Definition
Composition passes wired elements as slots. Context broadcasts a value to all descendants — every consumer re-renders when value identity changes.
'use client';
import { useState, type ReactNode } from 'react';
type User = { name: string; role: string };
function useSession() {
const [user] = useState<User>({ name: 'Alex', role: 'admin' });
const logout = () => console.log('logout');
return { user, logout };
}
function AppShell({ sidebar, children }: { sidebar: ReactNode; children: ReactNode }) {
return (
<div className="shell">
{sidebar}
<main>{children}</main>
</div>
);
}
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
return (
<nav>
<span>{user.name}</span>
<button type="button" onClick={onLogout}>Sign out</button>
</nav>
);
}
function DashboardPage() {
const { user, logout } = useSession();
return (
<AppShell sidebar={<UserMenu user={user} onLogout={logout} />}>
<h1>Dashboard</h1>
</AppShell>
);
}AppShell never imports User — coupling drops to one level. Default rule: composition first; Context for stable, broadly consumed values.
Interview:
{...rest}pass-through still couples intermediate components to the parent’s prop shape.
Server vs Client State
Definition
Server state is remote authoritative data (async, shared, goes stale). Client state is ephemeral UI (modal open, selected tab, form draft). One source of truth per fact.
// Server state — fetched, cached, shared
async function getOrders(userId: string) {
const res = await fetch(`/api/users/${userId}/orders`, {
next: { tags: [`orders-${userId}`] },
});
return res.json();
}'use client';
// Client state — UI only
function OrderFilters() {
const [status, setStatus] = useState<'open' | 'closed'>('open');
return (
<select value={status} onChange={(e) => setStatus(e.target.value as 'open' | 'closed')}>
<option value="open">Open</option>
<option value="closed">Closed</option>
</select>
);
}Examples: server = product catalog; client = sidebar collapsed. Auth session = server (cookie/JWT validated server-side).
Interview: Copying
/api/usersinto Zustand without invalidation → stale UI, double fetches, conflicting truth on mutation.
App Router Boundaries
Definition
Server Components (default) → async fetch, cache(), revalidateTag; zero client JS for reads
Client Components → useState, React Query for interactive refetch
Mutations → Server Actions or route handlers + cache invalidation
Anti-pattern → useEffect fetch for data RSC already fetched// app/orders/page.tsx — Server Component
export default async function OrdersPage() {
const orders = await fetchOrders();
return <OrdersClient initialOrders={orders} />;
}'use client';
export function OrdersClient({ initialOrders }: { initialOrders: Order[] }) {
const { data = initialOrders } = useQuery({
queryKey: ['orders'],
queryFn: fetchOrdersClient,
initialData: initialOrders,
staleTime: 30_000,
});
const [expandedId, setExpandedId] = useState<string | null>(null);
// ...
}Pass serializable server data as initial props to seed React Query.
React Query vs Zustand
Definition
Server data needs staleness, deduping, background refetch, and invalidation — React Query implements this. Zustand is a sync client store.
const useSelection = create<{ selectedId: string | null; select: (id: string) => void }>(
(set) => ({ selectedId: null, select: (id) => set({ selectedId: id }) }),
);
function UserPanel({ userId }: { userId: string }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const selectedId = useSelection((s) => s.selectedId);
return <div>{user?.name} — selected row {selectedId}</div>;
}Hybrid pattern → Query owns remote records; Zustand owns selection IDs, drawer state
Anti-pattern → fetch success → copy to Zustand → mutation elsewhere → Zustand staleInterview: Server cache belongs in React Query. Zustand owns UI chrome and cross-route selection — not HTTP responses.
Mutations & Optimistic UI
Definition
Server (truth) → cart lines in DB/session; mutated via Server Action + revalidateTag
Client (overlay) → useOptimistic on server props; drawer open in useState
URL filters → searchParams — server reads for SSR-aligned first paint
Never → parallel Zustand cart array without syncFour decision axes: update frequency, consumer count, distance in tree, lifetime.
Senior Interview Summary
Props vs state
Props are read-only inputs. State is owned data. One fact, one owner. Derive — do not duplicate.
Prop drilling
Pass-through props through layers that do not consume them — maintainability smell, not perf bug. Fix with composition slots first.
Server vs client
Server state is remote truth with cache and invalidation. Client state is ephemeral UI. Query owns HTTP data; Zustand owns UI chrome.
State ownership checklist
Product catalog → RSC fetch or React Query
Modal open → useState or Zustand
Filters → URL searchParams (server reads for SSR)
Cart lines → server DB + revalidateTag
Optimistic UI → overlay on server props, not parallel storeBest mental model
Props → "Parent configures child."
Lifted state → "Ancestor owns shared truth."
Derived → "Compute, don't duplicate."
Server state → "Remote, async, goes stale."
Client state → "Local, sync, dies with tab."Practical rule
One layout pass-through → acceptable
Six shells forwarding user → refactor to slots
10+ updates/sec on slice → store with selector, not Context
Document ownership → ADR per feature: who owns what, which tool