Implement useState derivation in a product component
Use useState derivation to solve a realistic React workflow. Keep rendering, user intent, async synchronization, and error states separate.
Answer Strategy
For useState derivation, explain ownership in React vocabulary: render-derived values stay out of state, user intent is handled in events, external synchronization lives in effects, and complex transitions move into a reducer.
The interview win is not using more hooks; it is choosing the smallest hook boundary that makes stale closures, repeated actions, and unmount cleanup obvious.
The reference implementation below gives you a reusable product-screen pattern: reducer for transitions, request id for stale-response protection, AbortController for cleanup, and view state derived at render time.
Reference Implementation: React Boundary For useState derivation
Use this pattern when a component must coordinate user intent with asynchronous server data without letting an older request overwrite a newer decision.
type ProductState<T> =
| { tag: 'idle' }
| { tag: 'loading'; requestId: number; previous?: T }
| { tag: 'success'; data: T; requestId: number }
| { tag: 'error'; message: string; requestId: number; previous?: T };
type ProductAction<T> =
| { type: 'start'; requestId: number }
| { type: 'success'; requestId: number; data: T }
| { type: 'error'; requestId: number; message: string };
function productReducer<T>(
state: ProductState<T>,
action: ProductAction<T>
): ProductState<T> {
if (action.type === 'start') {
return {
tag: 'loading',
requestId: action.requestId,
previous: state.tag === 'success' ? state.data : undefined,
};
}
if ('requestId' in state && action.requestId !== state.requestId) {
return state;
}
if (action.type === 'success') {
return { tag: 'success', data: action.data, requestId: action.requestId };
}
return {
tag: 'error',
message: action.message,
requestId: action.requestId,
previous: state.tag === 'loading' ? state.previous : undefined,
};
}
function useProductResource<T>(
key: string,
load: (signal: AbortSignal) => Promise<T>
) {
const [state, dispatch] = React.useReducer(productReducer<T>, { tag: 'idle' });
const requestId = React.useRef(0);
React.useEffect(() => {
const controller = new AbortController();
const id = ++requestId.current;
dispatch({ type: 'start', requestId: id });
load(controller.signal)
.then((data) => {
if (!controller.signal.aborted) {
dispatch({ type: 'success', requestId: id, data });
}
})
.catch((error) => {
if (!controller.signal.aborted) {
dispatch({
type: 'error',
requestId: id,
message: error instanceof Error ? error.message : 'Unknown error',
});
}
});
return () => controller.abort();
}, [key, load]);
const isStale = state.tag === 'loading' && state.previous !== undefined;
return { state, isStale };
}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.
test('productReducer ignores stale async responses', () => {
const loading = productReducer<{ name: string }>(
{ tag: 'idle' },
{ type: 'start', requestId: 2 }
);
const stale = productReducer(loading, {
type: 'success',
requestId: 1,
data: { name: 'Old result' },
});
expect(stale).toBe(loading);
const fresh = productReducer(loading, {
type: 'success',
requestId: 2,
data: { name: 'Fresh result' },
});
expect(fresh).toEqual({
tag: 'success',
requestId: 2,
data: { name: 'Fresh result' },
});
});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?