Back to blog
Frontend Engineeringadvanced

React Senior Interview: Every Concept Explained with Real-World Analogies

Virtual DOM, hooks, state management, performance, concurrent features, server state — every senior React question answered with the mental model that makes it click. Real examples from production apps.

SystemForgeApril 20, 202625 min read
ReactInterview PrepHooksPerformanceState ManagementTypeScriptReact QueryConcurrent ReactFrontend Architecture
Share:𝕏

Why Senior React Interviews Are Different

Junior questions test whether you know the API. Senior questions test whether you understand the why behind it — and whether you have been burned by the edge cases.

"What does useEffect do?" is a junior question. "Why does your useEffect run in an infinite loop, and what does that tell you about your component's data flow?" is a senior question.

This article explains every major React concept through analogies and real production scenarios — the way you should explain them in an interview.


1. The Virtual DOM — "The Efficient House Painter"

The Problem React Solves

Directly manipulating the browser's DOM is slow. Every change — adding a class, updating text, inserting a list item — triggers layout recalculations and repaints in the browser. If you update 50 things at once through direct DOM manipulation, you get 50 repaints.

The Analogy

A house painter is called to repaint one wall. Instead of repainting the entire house (which would take all day), he:

  1. Takes a photograph of every room before and after the change
  2. Compares the photos to find exactly what changed
  3. Only repaints the specific wall that is different
  • The photos = the Virtual DOM (React's lightweight in-memory copy of the UI)
  • Comparing photos = reconciliation (React's diff algorithm)
  • The actual painting = DOM update (the expensive browser operation)

React batches all your state changes, calculates the minimum number of actual DOM operations needed, then applies them in one pass. One repaint, not fifty.

The key Prop — Helping React Compare Photos

The problem: You have a list of 100 items. You remove the first one. Without key, React does not know which item was removed — it compares position by position and assumes item 1 changed, item 2 changed, ... through all 100. It re-renders everything.

With key={item.id}: React sees "item with id=42 is gone, items 43–141 are untouched." It removes one DOM node and leaves the rest alone.

The anti-pattern: key={index}. If you use the array index as a key and the list reorders or an item is removed from the middle, the keys shift. React thinks completely different items changed. Worse: controlled inputs (text fields) retain their DOM node but their content maps to a different item — you see the wrong text in the wrong input.

Real scenario: A hospital dashboard with a live list of 500 patient status cards. Without stable keys, every 30-second status poll re-renders all 500 cards. With key={patient.id}, only the 3 patients whose status actually changed re-render.


2. useState — "The Whiteboard in the Room"

The Concept

useState gives a component its own persistent memory — a value that survives re-renders and, when changed, triggers the component to re-render with the new value.

The Analogy

A whiteboard in a meeting room. Everyone in the meeting (the component) can see it. When someone erases and writes a new value, everyone in the room immediately sees the update and can react to it.

The whiteboard (state) persists across discussions (re-renders). But if everyone leaves and a new meeting starts in the same room (component unmounts and remounts), the whiteboard is wiped clean.

The Stale Closure Trap

TSX
const [count, setCount] = useState(0);

// WRONG — count is captured from the closure at the time this ran
setCount(count + 1); // if called twice quickly, both read count = 0

// CORRECT — functional update reads the latest state
setCount(prev => prev + 1); // each call gets the actual current value

Analogy: Two people update the whiteboard simultaneously. Both read "5", both add 1, both write "6". The board should say "7". This is a race condition. The functional form says "whatever is on the board right now, add 1" — so the second person sees "6" and writes "7."


3. useEffect — "The Side Effects Janitor"

The Concept

Components are pure functions of state + props → UI. But real apps need side effects: fetching data, subscribing to events, setting up timers, syncing with external systems. useEffect is the designated place for side effects — it runs after the render, not during it.

The Analogy

A theatre production. The actors (component) perform their scene (render the UI) based on the script (state + props). The janitor (useEffect) comes on after the scene ends to set up props for the next scene, clean up from the last one, and manage things that happen outside the performance itself.

The janitor does not interrupt the performance. He works after the curtain falls.

The Dependency Array — "When Does the Janitor Come Back?"

TSX
useEffect(() => { ... });              // After every render — janitor after every scene
useEffect(() => { ... }, []);          // After first render only — janitor sets up on opening night
useEffect(() => { ... }, [userId]);    // When userId changes — janitor when the lead actor changes

The Infinite Loop Trap

TSX
// This loops forever
useEffect(() => {
  setData(fetchData()); // state change → re-render → effect runs → state change → ...
}, [data]); // depending on the value you are setting

Analogy: The janitor's job is to tidy up after the actor. But tidying triggers the actor to re-enter the stage, which requires the janitor to tidy again. The performance never ends.

Fix: separate the trigger (the event that starts the fetch) from the result (the data you store). The dependency array should contain inputs to the effect, not outputs.

The Cleanup Function — "Strike the Set"

TSX
useEffect(() => {
  const subscription = websocket.subscribe(channel);
  return () => subscription.unsubscribe(); // cleanup on unmount or before next effect
}, [channel]);

Analogy: The janitor must also strike the set — remove the previous scene's props before setting up the new one. Without cleanup: subscriptions stack up, memory leaks grow, you receive events from WebSocket connections you thought were closed.

Real scenario: A live patient monitor subscribes to a WebSocket feed. When the user navigates to a different patient, the old subscription must be cancelled. Without the cleanup return, you would receive — and act on — data from both patients simultaneously.


4. useCallback and useMemo — "The Lazy Calculator"

The Problem

React re-renders components when state or props change. When a parent re-renders, every function it defines is recreated as a new object. Child components that receive those functions as props see "new prop" and also re-render — even if the function logic is identical.

The Analogy

Without memoisation: You have a complex maths problem. Your assistant solves it fresh every time you ask — even if the inputs have not changed. You ask for √144 every second. They recalculate 3,600 times per hour.

With memoisation (useMemo): Your assistant writes the answer on a sticky note. If you ask for √144 again, they check the note first. Only when the inputs change do they recalculate.

useCallback = memoises a function reference (the calculator itself) useMemo = memoises a computed value (the calculator's result)

TSX
// Without: new function reference every render → child always re-renders
const handleClick = () => doSomething(userId);

// With useCallback: same reference if userId hasn't changed
const handleClick = useCallback(() => doSomething(userId), [userId]);

// Without: sorts 10,000 devices on every render
const sorted = devices.sort((a, b) => a.name.localeCompare(b.name));

// With useMemo: only re-sorts when devices array changes
const sorted = useMemo(
  () => [...devices].sort((a, b) => a.name.localeCompare(b.name)),
  [devices]
);

When NOT to Use Them

Memoisation has a cost — React must store the previous value and compare dependencies on every render. For cheap operations, the memoisation overhead is greater than the re-render cost.

Rule of thumb:

  • useMemo — when the computation is measurably expensive (sorting/filtering large lists, complex transforms)
  • useCallback — only when passing to a child wrapped in React.memo, or as a dependency of another hook

Anti-pattern: wrapping everything in useCallback "just to be safe." This adds overhead to every render and makes the code harder to read with no performance benefit.


5. React.memo — "The Lazy Stage Manager"

The Concept

By default, when a parent re-renders, all its children re-render too — even if their props did not change. React.memo wraps a component and tells React: "only re-render this if its props actually changed."

The Analogy

A stage manager oversees 50 actors. Every time the director makes a change, the stage manager would normally wake up all 50 actors. React.memo is like giving each actor a note: "only get out of your chair if your lines changed."

TSX
// This component only re-renders if deviceId or status changes
const DeviceCard = React.memo(({ deviceId, status, onCalibrate }) => {
  return <div>{deviceId}: {status}</div>;
});

The Pitfall: Object and Function Props

React.memo uses shallow equality (===). Objects and functions are compared by reference, not by value.

TSX
// BAD — new object literal every render → memo never works
<DeviceCard config={{ threshold: 5 }} />

// GOOD — stable reference
const config = useMemo(() => ({ threshold: 5 }), []);
<DeviceCard config={config} />

Analogy: The stage manager checks if the script page changed. If the director reprints the same page with a different font (new object reference, same content), the stage manager still wakes the actor. You must give the same physical page for the memo to work.


6. useContext — "The Hotel Intercom System"

The Concept

Props flow from parent to child, one level at a time. If a deeply nested component needs data from a top-level component, you must pass the prop through every intermediate component — even ones that do not use it. This is "prop drilling."

useContext provides a broadcast channel that any component can tune into, regardless of depth.

The Analogy

A hotel with 20 floors. The front desk needs to notify every room about a fire drill. Without an intercom, they would have to phone Room 101 and ask them to knock on 102 who knocks on 103... through all 200 rooms. Inefficient, and floors 2–19 are just relaying messages they do not care about.

The hotel intercom (Context) broadcasts to every room simultaneously. Each room tunes in only if they need to hear it.

TSX
// The intercom broadcast channel
const ThemeContext = createContext<Theme>('light');

// The front desk (provider)
function App() {
  return (
    <ThemeContext.Provider value="dark">
      <Floor1 />   {/* doesn't use theme */}
      <Floor2 />   {/* doesn't use theme */}
      <DeepComponent /> {/* directly tunes in */}
    </ThemeContext.Provider>
  );
}

// The room that actually needs it
function DeepComponent() {
  const theme = useContext(ThemeContext); // no prop drilling
  return <div className={theme}>...</div>;
}

When NOT to Use Context

Context is for global, infrequently changing state: theme, locale, authenticated user, feature flags.

Context is NOT a replacement for state management. Every consumer re-renders when the context value changes. If you put frequently-changing data (a list of 500 devices updating every second) in context, every component using that context re-renders on every update — catastrophic for performance.

Analogy: The hotel intercom is excellent for occasional announcements. If the front desk broadcasts every guest check-in in real time (hundreds per hour), every room's intercom blares constantly. Use the intercom for important, infrequent messages. For real-time data, use a dedicated channel (React Query, Zustand).


7. useReducer — "The Redux Pattern in One Hook"

The Concept

When state logic becomes complex — multiple related fields, state transitions that depend on current state, actions that must update multiple fields atomically — useReducer provides a structured alternative to multiple useState calls.

The Analogy

A bank account manager with a ledger. Instead of directly editing numbers in the ledger, clients submit instructions:

  • "DEPOSIT £500"
  • "WITHDRAW £200"
  • "FREEZE account"

The manager applies the instructions in order against the current ledger state. Nobody directly modifies the balance — they submit transactions. This creates a clear audit trail and prevents invalid states (e.g., withdrawing from a frozen account — the manager's rules reject that instruction).

TSX
type Action =
  | { type: 'DEPOSIT'; amount: number }
  | { type: 'WITHDRAW'; amount: number }
  | { type: 'FREEZE' };

function accountReducer(state: Account, action: Action): Account {
  switch (action.type) {
    case 'DEPOSIT':
      return { ...state, balance: state.balance + action.amount };
    case 'WITHDRAW':
      if (state.frozen) return state; // reject invalid transition
      if (state.balance < action.amount) return state; // reject overdraft
      return { ...state, balance: state.balance - action.amount };
    case 'FREEZE':
      return { ...state, frozen: true };
  }
}

When to choose useReducer over useState:

  • Multiple state fields that update together
  • Next state depends on previous state in complex ways
  • State transitions must validate the current state before applying

8. Custom Hooks — "The Reusable Procedure"

The Concept

A custom hook extracts stateful logic into a reusable function. It is not a component — it renders nothing. It is a procedure for sharing behaviour across components.

The Analogy

A hospital has a standard procedure for admitting a patient: verify insurance, assign a bed, create a chart, notify the on-call doctor. This procedure is the same whether the patient arrives via emergency, scheduled admission, or transfer.

Instead of each ward duplicating this logic, the hospital writes a standard admission protocol. Any ward follows the protocol — the logic is in one place.

TSX
// The reusable protocol
function useDeviceStatus(deviceId: string) {
  const [status, setStatus] = useState<DeviceStatus | null>(null);
  const [error, setError] = useState<Error | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const controller = new AbortController();
    fetchDeviceStatus(deviceId, { signal: controller.signal })
      .then(setStatus)
      .catch(setError)
      .finally(() => setIsLoading(false));
    return () => controller.abort();
  }, [deviceId]);

  return { status, error, isLoading };
}

// Any component that needs device status
function DevicePanel({ deviceId }: { deviceId: string }) {
  const { status, isLoading } = useDeviceStatus(deviceId); // use the protocol
  if (isLoading) return <Spinner />;
  return <StatusBadge status={status} />;
}

The test advantage: custom hooks can be tested independently with renderHook — without rendering any UI. The logic is isolated and verifiable.


9. Server State vs Client State — "The Menu vs Your Order"

The Concept

Two fundamentally different types of state require different tools:

| | Server state | Client state | |---|---|---| | Lives in | A database on a server | The browser's memory | | Owned by | The server — other users can change it | Your component | | Challenges | Caching, staleness, synchronisation | Just what you set it to | | Examples | Product list, user profile, orders | Is the modal open? Which tab is selected? |

The Analogy

Server state = a restaurant menu. It is printed by the restaurant. Other diners can look at it. The restaurant can update it at any time. Your copy might be out of date. You need to "fetch" a fresh copy to see today's specials.

Client state = your current order. Only you have it. You decide what is on it. Nobody else can see or change it. It lives and dies with your visit to the restaurant.

Using useState to manage server state is like writing the restaurant menu on your own notepad and never checking if it changed. You will serve customers stale data.

React Query — "The Smart Menu Fetcher"

React Query manages server state: fetching, caching, background refreshing, retry on error, deduplication of identical requests.

TSX
// Without React Query — reinventing the wheel in every component
function DeviceList() {
  const [devices, setDevices] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/devices')
      .then(r => r.json())
      .then(setDevices)
      .catch(setError)
      .finally(() => setLoading(false));
  }, []);
}

// With React Query — all edge cases handled
function DeviceList() {
  const { data: devices, isLoading, error } = useQuery({
    queryKey: ['devices'],
    queryFn: () => fetch('/api/devices').then(r => r.json()),
    staleTime: 30_000,      // treat as fresh for 30 seconds
    refetchOnWindowFocus: true, // refresh when user returns to the tab
  });
}

React Query handles automatically:

  • Deduplication (two components requesting the same data → one fetch)
  • Background refetch when the tab regains focus
  • Retry with exponential backoff on network errors
  • Cache invalidation after mutations
  • Loading/error/success states

Real scenario: A fleet management dashboard with 20 widgets all displaying different slices of device data. Without React Query: 20 separate useEffect fetches, each with its own loading state, no sharing, no caching. With React Query: requests with the same queryKey deduplicate — 20 widgets, potentially 3 actual network requests.


10. Optimistic Updates — "The Impatient Barista"

The Concept

When a user takes an action (like the barista), you immediately update the UI as if it succeeded — before the server responds. If the server responds with success, nothing changes. If it fails, you roll back.

The Analogy

You order a coffee at a busy café. The barista immediately says "Of course! Coming right up" and starts making your order — they do not wait for the kitchen to confirm they have milk first. If they run out of milk, they apologise and offer an alternative (rollback). 99% of the time, it just works, and you feel the service is instant.

TSX
const mutation = useMutation({
  mutationFn: (deviceId: string) => assignDevice(deviceId),

  // Immediately update the UI
  onMutate: async (deviceId) => {
    await queryClient.cancelQueries({ queryKey: ['devices'] });
    const previous = queryClient.getQueryData(['devices']); // snapshot for rollback

    queryClient.setQueryData(['devices'], (old: Device[]) =>
      old.map(d => d.id === deviceId ? { ...d, status: 'assigned' } : d)
    );

    return { previous }; // pass snapshot to onError
  },

  // Server said no — roll back
  onError: (err, deviceId, context) => {
    queryClient.setQueryData(['devices'], context?.previous);
    toast.error('Assignment failed — please try again');
  },

  // Confirm with fresh server data
  onSettled: () => queryClient.invalidateQueries({ queryKey: ['devices'] }),
});

When NOT to use optimistic updates:

  • Financial transactions (charging a card, transferring money — must confirm before updating)
  • Irreversible actions (deleting data — show a confirmation first)
  • Low-confidence operations (anything with >5% expected failure rate)

11. React Performance — "The Formula 1 Pit Stop"

The Concept

Performance optimisation in React is about reducing unnecessary work — not doing more work faster, but doing less work altogether.

The three main sources of unnecessary work:

  1. Unnecessary re-renders — a component re-renders when its props/state did not meaningfully change
  2. Expensive computations on every render — sorting 10,000 items every time any state changes
  3. Loading too much upfront — the initial bundle includes code the user may never need

Virtualisation — "Only Build What You Can See"

Rendering 10,000 DOM nodes for a list that shows 20 at a time is wasteful. Virtualisation renders only the visible rows — plus a small buffer above and below.

Analogy: A theatre seating 10,000 people. You only need to set out chairs for the section the audience can currently see. As people walk down the aisle (scroll), you quickly set out chairs just ahead of them and remove chairs behind them. The audience never notices — but you only ever have 50 chairs in existence at a time.

TSX
import { useVirtualizer } from '@tanstack/react-virtual';

function DeviceList({ devices }: { devices: Device[] }) {
  const parentRef = useRef<HTMLDivElement>(null);

  const virtualizer = useVirtualizer({
    count: devices.length,    // 10,000 devices
    getScrollElement: () => parentRef.current,
    estimateSize: () => 60,   // each row is ~60px tall
    overscan: 5,              // render 5 rows outside viewport as buffer
  });

  return (
    <div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
      <div style={{ height: virtualizer.getTotalSize() }}>
        {virtualizer.getVirtualItems().map(vItem => (
          <div key={vItem.key} style={{ transform: `translateY(${vItem.start}px)` }}>
            <DeviceRow device={devices[vItem.index]} />
          </div>
        ))}
      </div>
    </div>
  );
}

Real example: Laerdal fleet management showing 5,000 devices. Without virtualisation: browser renders 5,000 DOM nodes, page takes 4+ seconds to become interactive. With virtualisation: browser renders ~20 nodes at any time, instant interaction.

Code Splitting — "Load What You Need, When You Need It"

Analogy: A book delivery service. Without code splitting, they deliver the entire encyclopaedia (all 26 volumes) before you can start reading Volume A. With code splitting, they deliver Volume A immediately. You can start reading while Volume B is still in transit.

TSX
// Lazy load heavy components
const CalibrationChart = lazy(() => import('./CalibrationChart'));
const ReportGenerator = lazy(() => import('./ReportGenerator'));

function DeviceDetail() {
  const [showChart, setShowChart] = useState(false);

  return (
    <>
      <button onClick={() => setShowChart(true)}>Show History</button>
      {showChart && (
        <Suspense fallback={<Spinner />}>
          <CalibrationChart /> {/* loaded only when needed */}
        </Suspense>
      )}
    </>
  );
}

12. Suspense and Concurrent Features — "The Polite Queue Manager"

The Problem

Before concurrent React, rendering was synchronous — once React started rendering, it could not stop. A slow component (fetching data, loading code) would block the entire UI.

The Analogy

Old React: A single checkout lane at a supermarket. If one customer has 200 items, everyone else waits — no exceptions.

Concurrent React: A smart queue manager. If your lane is slow, the cashier can temporarily pause and check on other lanes. Urgent work (a user typing in a search box) interrupts lower-priority work (loading a background analytics widget). The manager ensures the most important experience is always fluid.

Suspense — "The Placeholder Menu"

While a component is loading (code or data), React shows a fallback. The rest of the app remains interactive.

Analogy: A restaurant hands you the drinks menu immediately while the kitchen prepares the full menu. You are not staring at a blank table — you have something to interact with.

TSX
function Dashboard() {
  return (
    <div>
      <Header />  {/* always visible */}
      <Suspense fallback={<DashboardSkeleton />}>
        <DeviceStats />    {/* shows skeleton while loading */}
      </Suspense>
      <Suspense fallback={<ChartSkeleton />}>
        <MaintenanceChart />   {/* loads independently */}
      </Suspense>
    </div>
  );
}

useTransition — "The Non-Urgent Lane"

TSX
const [isPending, startTransition] = useTransition();

function handleSearch(query: string) {
  setInputValue(query);           // urgent — update immediately
  startTransition(() => {
    setFilteredDevices(           // non-urgent — can be interrupted
      devices.filter(d => d.name.includes(query))
    );
  });
}

Analogy: You are typing in a search box. The text you type (input state) must update immediately — that is urgent. The list of results filtering to match what you typed — that can be slightly delayed. useTransition says: "the input is urgent, the filter is not." If the user types faster than the filter can run, React cancels the in-progress filter and starts a new one. The typing never feels laggy.


13. Component Architecture — "Separation of Concerns"

The Container / Presentational Pattern

Container component (smart): knows about data fetching, state, business logic. No styling.

Presentational component (dumb): receives data via props, renders UI. No fetching.

Analogy:

  • Container = the chef. Decides what dish to make, sources the ingredients, applies the recipe.
  • Presentational = the waiter. Takes the finished plate and serves it. Does not care how it was made.
TSX
// Container — knows about data
function DeviceStatusContainer({ deviceId }: { deviceId: string }) {
  const { data, isLoading } = useDeviceStatus(deviceId);
  const mutation = useAssignDevice();

  return (
    <DeviceStatusCard
      device={data}
      isLoading={isLoading}
      onAssign={mutation.mutate}
    />
  );
}

// Presentational — only knows about display
function DeviceStatusCard({ device, isLoading, onAssign }) {
  if (isLoading) return <Skeleton />;
  return (
    <div>
      <h3>{device.name}</h3>
      <StatusBadge status={device.status} />
      <button onClick={() => onAssign(device.id)}>Assign</button>
    </div>
  );
}

The test benefit: DeviceStatusCard can be tested and developed in Storybook with any device data passed as props — no network calls, no mocking, no setup. The container's data logic is tested separately.


14. TypeScript with React — "The Strict Building Inspector"

The Concept

TypeScript is a build-time contract enforcer. It catches mistakes before they reach the browser.

The Analogy

A building inspector who reviews blueprints before construction starts. It is far cheaper to catch a structural error on paper than after the walls are up.

TypeScript catches:

TSX
// Calling a function with the wrong argument type — caught at compile time
<DeviceCard deviceId={42} />   // Error: expected string, got number

// Accessing a property that might not exist
device.calibration.lastDate    // Error: calibration might be null

// Missing required prop
<DeviceCard />                 // Error: deviceId is required

Discriminated Unions — "The State Machine Type"

TSX
// Without discriminated union — messy, unsafe
type AsyncState = {
  isLoading: boolean;
  data?: Device;
  error?: Error;
};
// You can accidentally have isLoading: true AND data defined — impossible state

// With discriminated union — impossible states are unrepresentable
type AsyncState =
  | { status: 'loading' }
  | { status: 'success'; data: Device }
  | { status: 'error'; error: Error };

// TypeScript narrows the type based on status
switch (state.status) {
  case 'success':
    return <DeviceCard device={state.data} />; // TS knows data exists
  case 'error':
    return <ErrorBanner error={state.error} />; // TS knows error exists
}

Analogy: Electrical plugs. A UK plug physically cannot fit into a US socket — impossible by design. Discriminated unions make invalid states physically impossible in the type system.


15. Testing React Components — "The Three Layers"

The Analogy: A Car Factory

| Test type | Car factory equivalent | Tests | |---|---|---| | Unit tests | Test each individual part — does the brake caliper compress correctly? | Individual components, custom hooks in isolation | | Integration tests | Test assembled sections — does the braking system work end-to-end? | Component with its real children, real React Query, real routing | | E2E tests | Drive the finished car on a real road | User flows in a real browser: login → view list → filter → click → result |

What to Test (and What Not To)

Test user behaviour, not implementation details.

TSX
// BAD — testing implementation (internal state, class names)
expect(wrapper.state('isOpen')).toBe(true);
expect(wrapper.find('.dropdown-open')).toHaveLength(1);

// GOOD — testing what the user sees and can do
await userEvent.click(screen.getByRole('button', { name: 'Assign Device' }));
expect(await screen.findByText('Device assigned successfully')).toBeInTheDocument();

Analogy: A car inspector should test "does the car stop when I press the brake?" not "is the brake caliper part number CS-441?" You care about the observable behaviour, not the internal implementation.

If you refactor the component's internals (changing state shape, renaming CSS classes) — the tests should not break. If they do, they are testing implementation, not behaviour.


The Senior Mental Checklist

When reviewing or building a React component, a senior engineer mentally runs through:

Rendering
  □ Are keys stable and unique (not array indexes)?
  □ Is any expensive computation inside the render without useMemo?
  □ Are functions passed as props stable across renders (useCallback)?

Data
  □ Is server state managed by React Query, not useState + useEffect?
  □ Are loading/error/empty states all handled?
  □ Is the query key specific enough to avoid stale cache collisions?

Effects
  □ Does every subscription in useEffect have a cleanup?
  □ Is the dependency array complete (no missing deps)?
  □ Could this effect be replaced with an event handler?

TypeScript
  □ Are impossible states prevented with discriminated unions?
  □ Are nullable values handled before access?

Performance (only when needed)
  □ Is the list long enough to need virtualisation? (>200 items)
  □ Is the component pure enough to benefit from React.memo?
  □ Should heavy code be lazy-loaded?

Summary — Every Concept in One Line

| Concept | Core mental model | |---|---| | Virtual DOM | Compare photos, only repaint what changed | | key prop | Give React a stable identity to track list items | | useState | A whiteboard that triggers re-renders when erased and rewritten | | useEffect | The janitor who works after the actors leave the stage | | useCallback | Cache the function reference so child components don't re-render | | useMemo | Cache the computed value so expensive work doesn't repeat | | React.memo | Don't wake the actor unless their lines actually changed | | useContext | The hotel intercom — broadcast to any depth without prop drilling | | useReducer | Submit instructions to a ledger manager instead of editing numbers directly | | Custom hooks | Reusable hospital admission protocols — same procedure, any ward | | Server state | Data that lives on a server and can change without you — use React Query | | Optimistic updates | The impatient barista — act immediately, roll back if wrong | | Virtualisation | Only render the rows the user can actually see | | Suspense | The placeholder menu while the kitchen prepares the real one | | useTransition | Mark work as non-urgent so typing always feels instant | | Discriminated unions | UK plug in a UK socket — impossible states are physically unrepresentable | | Testing | Test what the user sees, not how the component stores its state |

Enjoyed this article?

Explore the Frontend Engineering learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.