Implement server state versus client state in a product component
Use server state versus client state to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
Server state vs client state is the question that asks whether you can resist treating useState as one global drawer. Client state is user intent that lives in the view: draft text, focused row, expanded panels. Server state is durable truth that has to be cached, invalidated, and reconciled with concurrent writers. Fetching keystrokes is the canonical mistake — it conflates "user is thinking" with "user wants to commit", and the API gets a thousand requests per minute.
Two state stores, two cache lifetimes. Client state can be lost on unmount; rebuilding it from scratch is cheap. Server state must survive remounts and shared mounts (two tabs of the same query share results, two routes of the same product see the same row). The bridge between them is a commit boundary: form submit, button click, debounced commit — never the keystroke itself unless you have explicitly designed for typeahead with cancellation and dedup.
Adjacent traps: putting server state in component-local useState (it disappears on remount and gets refetched), putting client draft text in a global cache (it bleeds across views), and using useEffect with [draft] to fetch (commits intent that the user has not yet declared). The reference splits draft from committed and only the latter drives the effect — typing is free, search costs one request.
Reference Implementation: Draft vs Committed Search
A SearchPanel where typing updates only client draft state; submit promotes draft to committed and triggers exactly one server request.
// The split: client state owns user intent (filter inputs, draft text).
// Server state owns durable truth (persisted rows, IDs, totals). Treat
// them as different state systems with different cache rules.
type Row = { id: string; title: string; tag: string };
type Server = {
search: (query: string, signal: AbortSignal) => Promise<Row[]>;
};
type SearchPanelProps = { server: Server };
export function SearchPanel({ server }: SearchPanelProps) {
// Client state: typed but uncommitted intent. Lives only in this view,
// can be discarded on unmount, drives no network traffic until submit.
const [draft, setDraft] = React.useState('');
const [committed, setCommitted] = React.useState('');
// Server state: results keyed by the committed query. Refetching is
// user-triggered, not driven by every keystroke.
const [rows, setRows] = React.useState<Row[]>([]);
const [status, setStatus] = React.useState<'idle' | 'loading' | 'error'>('idle');
React.useEffect(() => {
if (!committed) return;
const controller = new AbortController();
setStatus('loading');
server
.search(committed, controller.signal)
.then((result) => {
setRows(result);
setStatus('idle');
})
.catch((error: Error) => {
if (controller.signal.aborted) return;
setStatus('error');
});
return () => controller.abort();
}, [committed, server]);
return (
<form
onSubmit={(event)=> {
event.preventDefault();
// Submit is the only place client intent is promoted to a server
// request. Typing does not refetch — the rule that prevents the
// typeahead-bursts-the-API class of bugs.
setCommitted(draft.trim());
}}
>
<input
aria-label="Search"
value={draft}
onChange={(event)=> setDraft(event.target.value)}
/>
<button type="submit">Search</button>
{status === 'loading' && <p>Loading...</p>}
{status === 'error' && <p role="alert">Search failed.</p>}
<ul>
{rows.map((row) => (
<li key={row.id}>{row.title}</li>
))}
</ul>
</form>
);
}Testing Strategy
Convert the answer into observable behavior. In a mid-senior interview, say which behaviors are covered by unit tests, interaction tests, accessibility checks, and one browser smoke path.
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
describe('SearchPanel', () => {
it('typing does not trigger server requests', () => {
const server = { search: vi.fn(async () => []) };
render(<SearchPanel server={server} />);
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'foo' } });
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'foob' } });
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'fooba' } });
expect(server.search).not.toHaveBeenCalled();
});
it('submit promotes draft to committed and fetches once', async () => {
const server = {
search: vi.fn(async () => [{ id: '1', title: 'Foo Result', tag: 'a' }]),
};
render(<SearchPanel server={server} />);
fireEvent.change(screen.getByLabelText('Search'), { target: { value: 'foo' } });
fireEvent.click(screen.getByRole('button', { name: 'Search' }));
await waitFor(() => expect(screen.getByText('Foo Result')).toBeInTheDocument());
expect(server.search).toHaveBeenCalledTimes(1);
expect(server.search).toHaveBeenCalledWith('foo', expect.any(AbortSignal));
});
});Interviewer Signal
Shows whether you can explain React behavior while building maintainable product UI.
Constraints
- Name what is render-derived and what is stored state.
- Keep side effects owned by events or effects deliberately.
- Provide a testable boundary for business logic.
Model Answer Shape
- Start from the user workflow and state ownership.
- Move pure decisions out of JSX when the branch logic grows.
- Use React primitives to express ownership, not to hide unclear state.
Tradeoffs
- Colocating state improves clarity until sibling coordination becomes the real problem.
- Memoization helps only after render cost or identity churn is measured.
Edge Cases
- Strict Mode re-running development effects.
- Stale closures after async work resolves.
- Unmounts and route changes during in-flight operations.
Testing And Proof
- Reducer or pure function test for core state transitions.
- Interaction test for the user workflow.
- Regression case for stale or repeated async behavior.
Follow-Ups
- How would this change with server rendering?
- Where would you place this state in a larger app?