← Back to question bank
React AppSeniorHard#4019 · 55m

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

For server state versus client state, 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 server state versus client state

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.

Reducer
Test legal and illegal state transitions without rendering React when the transition logic has branches.
Effects
Drive repeated requests, unmounts, and aborts so stale async work cannot update visible state.
Workflow
Use Testing Library for the user path: click, type, wait for result, retry, and verify error recovery.
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?