From Routines to a Crew — Building a System That Plans Its Own Work & executes it

The Gap

In Part 1, I built a routine board — a system that runs Claude on a schedule, defined as Markdown files, backed by a Rust engine with cron scheduling, crash recovery, and a self-contained dashboard. It works well for what it does.

But real work isn’t a cron job.

Consider this: you need to audit all the places a deprecated API is referenced across a large codebase. That means searching multiple code areas, cross-referencing findings, identifying which references are active vs. dead code, and producing a prioritized cleanup plan. No single Claude session handles this well. The context is too broad, the work needs decomposition, and some pieces depend on others.

That’s the difference between “task execution” and “work management.” Execution is running a prompt. Work management is deciding what to run, in what order, with what context, and what to do when something fails

The Build Sprint

I built the entire system — from nothing to a multi-persona task engine with a dashboard — in 3 days of evenings.

Day 1 was intense: core orchestrator (Phase 0.5), a full Rust dashboard with CRUD operations (Phase 0.75), and a round of silent-failure bug fixes (Phase 0.9) — all in one session. Day 3: hardening with task creation/editing/deletion from the dashboard, POSIX file locking for concurrency safety, and launchd scheduling (Phase 1), then the big architectural addition — planner/worker decomposition with a Worker Hive visualization (Phase 2).

The stack: Python for the orchestrator (subprocess management, YAML parsing, straightforward scripting), Rust for the dashboard (HTTP server, real-time worker status, the same “single binary” philosophy from the routine engine).

The recursive part: Claude helped build the system that orchestrates Claude. The design document, the orchestrator code, the Rust dashboard — all built with Claude as a pair programmer. I was designing a system for autonomous AI work while doing autonomous AI work. It’s turtles all the way down.

The Task Schema and Activity Log

Every task lives in a YAML file with a rich schema:

id: "TASK-001"
name: "Audit all deprecated API references"
description: |
  Search codebase for deprecated API references.
  Check related tasks for migration status.
priority: P0
size: L
status: open
type: investigation
requires_human: review
human_loop_mode: blocking
dependencies: []
sub_tasks: []
activity_log: []

Priority (P0–P3), size (XS–XL), status, dependencies, human intervention config — the usual project management primitives. But the real innovation is the activity log.

Every action on a task gets timestamped with who did it (which persona), what they did, and what they found:

activity_log:
  - ts: "2026-03-02T22:37:29"
    persona: worker
    action: picked_up
    detail: "Selected as highest priority unblocked task"
  - ts: "2026-03-02T22:42:10"
    persona: worker
    action: completed
    detail: "Research complete. Found 27 references across 6 categories."

This is the system’s memory — and it’s surprisingly powerful for how simple it is. When a task fails and retries, the retrying worker sees what was already attempted and tries a different approach. When a planner decomposes a task, it reads the log to understand context. When I look at a task at the end of the day, I can trace exactly what happened — which tools were used, what was found, what failed — without reading pages of raw Claude output.

The action types tell the story: picked_up, progress, planned, failed, retry, blocked, human_requested, human_responded, completed. You can scan a task’s log and understand its entire lifecycle in seconds. It’s the simplest possible implementation of agent memory, and it carries surprisingly far.

┌──────────┐
│   Open   │
└────┬─────┘
     │
┌────▼─────┐
│ Planning  │◄──────────────────────┐
└────┬─────┘                        │
     │                              │
┌────▼──────┐                       │
│ In Progress├──────────────────────┘
└────┬──────┘   (worker rejects plan → re-plan)
     │
┌────▼─────┐
│ Blocked   │  (needs human input or dependency)
└────┬─────┘
     │
┌────▼─────┐     ┌──────────────┐
│   Done   ├────►│ Spawn Follow-│
└──────────┘     │ up Tasks     │
                 └──────────────┘

Human-in-the-Loop as a Dial

One of the most useful design decisions was making human involvement a dial, not a switch. It’s a 2×2 matrix:

requires_human blocking non_blocking
none fully autonomous fully autonomous
review pauses before close closes, sends summary
intervention pauses at checkpoints continues with best guess
approval waits for plan sign-off —

A P0 investigation should pause for human review — the stakes are too high for full autonomy. A P3 documentation task can run end-to-end without anyone looking at it. A task that needs plan approval waits after the planner proposes sub-tasks, showing them in the dashboard with Approve/Reject buttons.

Different tasks need different autonomy levels, and the system supports that as a per-task configuration rather than a global setting. In practice, I found that most tasks start as requires_human: none (fully autonomous) and I only add friction for high-stakes work. The default is trust, with guardrails where they matter.

The Bugs That Taught Me

The most instructive bugs were all variations on the same theme: silent failure.

Empty output as success. Workers were returning exit code 0 with empty stdout — they’d gotten stuck on a permission prompt and hung until timeout. The orchestrator saw exit code 0 and marked the task as done. Fix: treat empty output as failure. A single if not output: check that routes through the failure handler:

if not output:
    msg = "Worker returned exit code 0 but produced no output"
    return False, msg

Timeout gap in loop mode. The continuous mode spawned workers as background processes and polled for completion, but it wasn’t tracking when each worker started. Workers could run forever, accumulating memory and burning API credits. Fix: track spawn_time per worker in the PID file (later enriched to full JSON metadata with persona and start time), check elapsed time each poll cycle, proc.kill() overdue workers.

Lock contention silence. When the orchestrator tried to run but another instance already held the POSIX flock, it would silently exit. No log entry, no notification, nothing. From the outside, it looked like the system stopped working — you’d check the schedule and see it should have run, but there’s no evidence it even tried. Fix: write a “Skipped — lock held” entry to the run log before exiting.

The meta-lesson: autonomous systems fail silently by default. You have to instrument every exit path, every edge case, every “this shouldn’t happen” branch. If a human isn’t watching, nobody is — unless you build the observability in.

Phase 2: Planners and Workers

The architecture shift in Phase 2 was routing tasks through different personas based on their size. Tasks sized M, L, or XL go through a “planner” that decomposes them into smaller sub-tasks. XS and S tasks go directly to workers — backward compatible with Phase 1 behavior.

The routing logic is remarkably compact — three checks that determine the entire system’s behavior:

def needs_planning(task):
    if task.get("size") not in {"M", "L", "XL"}: return False
    if task.get("parent_task"): return False  # sub-tasks skip planning
    if task.get("sub_tasks"): return False     # already planned
    return True

Not big? Not a parent’s child? Not already planned? Send it to the planner. Everything else goes to a worker. That’s the entire routing layer.

The planner gets a specialized prompt asking it to output structured JSON with $N dependency references. The orchestrator resolves $1, $2 etc. to actual TASK-NNN IDs when materializing sub-tasks:

id_map = {}  # $N -> actual task ID (1-indexed)
for i, spec in enumerate(sub_tasks_spec):
    new_id = f"TASK-{next_num:03d}"
    id_map[f"${i + 1}"] = new_id
    # Resolve $N dependencies to real IDs
    resolved_deps = [id_map.get(dep, dep) for dep in spec.get("dependencies", [])]

One subtle but important design choice: planners run before workers in the task queue. When the orchestrator selects the next task, it prioritizes planner work over worker work. This unblocks sub-tasks sooner — you don’t want a planner waiting behind three workers when its output would spawn three more parallelizable tasks.

When all sub-tasks complete, the parent auto-closes.

                    ┌──────────────────┐
                    │  Parent Task     │
                    │  (Size: L)       │
                    │  "Audit all API  │
                    │   endpoints"     │
                    └────────┬─────────┘
                             │
                       ┌─────▼─────┐
                       │  Planner   │
                       │  (Claude)  │
                       └─────┬─────┘
                             │ JSON plan with $N deps
               ┌─────────────┼─────────────┐
               │             │             │
        ┌──────▼──────┐ ┌───▼────────┐ ┌──▼───────────┐
        │ Sub-task 1  │ │ Sub-task 2 │ │ Sub-task 3   │
        │ (Size: XS)  │ │ (Size: S)  │ │ (Size: S)    │
        │ No deps     │ │ No deps    │ │ Depends: 1,2 │
        └──────┬──────┘ └─────┬──────┘ └──────┬───────┘
               │              │               │
          ┌────▼────┐   ┌─────▼────┐    ┌─────▼────┐
          │ Worker  │   │ Worker   │    │ Worker   │
          │ (Claude)│   │ (Claude) │    │ (Claude) │
          └─────────┘   └──────────┘    └──────────┘
               │              │               │
               └──────────────┼───────────────┘
                              │
                    All done → Parent auto-closes

The Plan Rejection Protocol

Here’s where it gets interesting. Workers can say “this plan is bad.”

If a sub-task’s plan is fundamentally unworkable — missing prerequisites, contradictory requirements, impossible constraints — the worker outputs PLAN_REJECTED: <reason> instead of completing the task. The orchestrator detects this marker, removes all the old sub-tasks, resets the parent task for re-planning, and includes the rejection reason in the next planner prompt.

┌──────────┐     plan      ┌──────────┐    sub-tasks    ┌──────────┐
│  Parent   │─────────────►│ Planner  │────────────────►│ Workers  │
│  Task     │              │          │                 │          │
└──────────┘              └──────────┘                 └────┬─────┘
     ▲                         ▲                            │
     │                         │     PLAN_REJECTED:         │
     │    reset parent,        │     "missing prereq X"     │
     │    include rejection    └────────────────────────────┘
     │    context
     │
     │   After 3 iterations:
     └── escalate to human intervention

max_plan_iterations defaults to 3. After three failed plan-reject cycles, the system escalates to human intervention — it sets requires_human: intervention and writes a notification explaining what happened.

This isn’t error handling. It’s a feedback loop between two AI personas. The planner proposes, the worker evaluates, and if the proposal doesn’t survive contact with reality, the system iterates. With context. The rejection reason is fed back to the planner, so each iteration is informed by what went wrong before.

Real Results

The system’s first real test was a codebase audit — finding all references to a deprecated API across a large repository, checking related tasks for migration status, and producing a prioritized cleanup plan. This is exactly the kind of task that’s painful to do manually: boring, sprawling, requires checking dozens of files and cross-referencing with issue trackers.

The planner decomposed it into 8 sub-tasks — each focused on a different code area or a different type of investigation (search this directory, check that task tracker, analyze this migration path). Workers ran independently, some completing in minutes (quick grep-style searches), others taking longer for deeper analysis.

Here’s what the workers found, collectively:

  • 27 distinct references across 6 categories, ranked into 3 priority tiers
  • 3 out-of-scope items correctly identified as false positives — things that looked like matches but were actually unrelated (different product’s API, different naming convention, already fully migrated). That’s human time saved: instead of chasing false positives, I got a pre-filtered list
  • 3 targets confirmed already removed — one sub-task discovered its target had been cleaned up in a previous effort over a year ago. The worker correctly reported “no changes needed” and moved on
  • One sub-task found that a supposedly abandoned migration task had actually been stalled since 2022 — useful context for prioritization

Final output: a structured action plan with 5 independent code changes, prioritized by risk and effort, with pre-checks and test plans for each. All the changes were small (XS or S sized) and could be submitted in parallel.

What would have taken a full day of manual investigation — opening files, cross-referencing tasks, checking git history, reading old code reviews — was done by 8 coordinated AI workers. And because each sub-task produced a standalone output file, I could review them individually, at my own pace, in whatever order made sense.

What I Learned

The evolution tells a story: cron job → scheduler → orchestrator → planner/worker system. Each step was driven by a real limitation of the previous one, not by architectural ambition.

V0 — Serial                 One worker, one task at a time
╔═══╗     ╔══════╗     ╔══════╗
║ W ║────►║ Task ║────►║ Task ║────► ...
╚═══╝     ╚══════╝     ╚══════╝


V1 — Worker Pool             Independent tasks, concurrent workers
╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗
║ W ║ ║ W ║ ║ W ║ ║ W ║      worker queue
╚═╤═╝ ╚═╤═╝ ╚═╤═╝ ╚═╤═╝
  │     │     │     │
  ▼     ▼     ▼     ▼
┌───┐ ┌───┐ ┌───┐ ┌───┐
│ T │ │ T │ │ T │ │ T │      task pool
└───┘ └───┘ └───┘ └───┘


V2 — Planners + Workers      Decomposition before execution
╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗
║ W ║ ║ W ║ ║ W ║ ║ W ║ ║ W ║ ║ W ║  workers
╚═══╝ ╚═══╝ ╚═══╝ ╚═══╝ ╚═══╝ ╚═══╝
╔═══╗ ╔═══╗ ╔═══╗
║ P ║ ║ P ║ ║ P ║                     planners
╚═══╝ ╚═══╝ ╚═══╝


V3 — Team (future)           Specialized personas, handoff protocol
╔═══╗ ╔═══╗
║ P ║ ║ P ║                           planners
╚═══╝ ╚═══╝
╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗ ╔═══╗
║ W ║ ║ W ║ ║ W ║ ║ W ║ ║ W ║ ║ W ║  workers
╚═══╝ ╚═══╝ ╚═══╝ ╚═══╝ ╚═══╝ ╚═══╝
╔════╗ ╔═══╗
║ PM ║ ║ TL║                          creators
╚════╝ ╚═══╝
       ▲ handoff protocol ▲

Four patterns emerged that I think are generalizable:

Activity log as memory. Context survives across retries and sessions because every action is recorded. A retry doesn’t start from zero — it starts from “here’s what was tried and why it failed.” This is the simplest possible implementation of agent memory, and it’s surprisingly effective.

Personas as routing logic. “Planner” and “worker” aren’t separate systems or separate models. They’re the same Claude CLI called with different prompts. The persona distinction is a function call — needs_planning() returns True, you use the planner prompt template. Returns False, you use the worker template. That’s it. No framework, no agent registry, no complex orchestration layer.

Human-in-the-loop as a dial. The 2×2 matrix of requires_human × blocking/non_blocking lets each task declare its own autonomy level. This is more useful in practice than a global “autonomous mode” toggle.

Plan rejection as protocol. Not error handling — a first-class feedback mechanism. PLAN_REJECTED: is part of the prompt contract. Both sides know the rules. The system iterates with context rather than retrying blindly.

The broader ecosystem is exploring similar ideas. Tools and frameworks for autonomous AI workflows are emerging rapidly. There’s no canonical architecture for this yet — and that’s what makes it an exciting space to build in. We’re all figuring this out in real time.

What’s Next

Phase 3 is where it gets ambitious. There were a few paths i considered for this. Either going deep on exactly defining the personas, for example: TL and PM personas that create new tasks etc. The other option was to focus on the shared ecosystem instead of the individual personas.
There will always be more & more creative agents with different capabilities continue to show up, so rather than focusing on a single agent with different flavor, i found it to be both very interesting & challenging to rather focus on the shared ecosystem they’d operate under. A space where devs and their agents could co-work in a productive way.

It stops being a tool you use and becomes a team you manage.

Closing

From a bash cron job to a multi-persona planning system in about 3 days of evenings. That’s not a testament to my engineering speed — it’s a testament to what becomes possible when you have an AI pair programmer that can help build the infrastructure for its own autonomy.

Honest assessment: it’s still experimental. The failure rate is real. Silent failures lurk in every corner, and you have to instrument your way to reliability. But the ceiling is visible.

The pattern of “structured task → AI decomposition → parallel execution → human review” works, and it works better than doing everything interactively.

If you have Claude Code or any AI with a CLI, try building something autonomous. Start with a single routine — a cron job that generates your standup. See where it takes you. You might end up with a planning system that argues with itself about how to approach your work.

And that’s a surprisingly useful thing to have.

This is Part 2 of a multi-part series. Part 1: “The Routine Board” covers the routine engine that started it all.

25 React Interview Questions 2026 (With Answers) — Hooks, React 19, Concurrent Mode

React powers the frontends of Facebook, Instagram, Netflix, Airbnb, and thousands of top tech startups. With over 40% market share among frontend frameworks, React remains the most sought-after skill for frontend developers in 2026.

Whether you’re preparing for a startup loop or a FAANG panel, this comprehensive list of 25 React interview questions—updated with React 19 paradigms and modern performance patterns—will ensure you stand out.

🚀 Fundamentals (Questions 1-8)

1. What is the Virtual DOM and how does React use it?

The Virtual DOM (VDOM) is an in-memory, lightweight representation of the real browser DOM.

When a component’s state or props change, React updates the Virtual DOM first. The process follows three main steps:

  1. Render: React generates a new Virtual DOM tree representing the updated UI.
  2. Reconciliation (Diffing): React compares the new Virtual DOM tree with the previous one using a highly optimized O(n) diffing algorithm.
  3. Commit: React computes the minimal set of changes needed and applies them to the real DOM (via batching), minimizing expensive layout recalculations in the browser.

2. What is JSX and why does React use it?

JSX (JavaScript XML) is a syntax extension for JavaScript that allows you to write HTML-like structures directly inside your JavaScript code.

JSX is not valid browser JavaScript; it must be compiled (typically using Babel or SWC) before execution. Under the hood, JSX compiles to standard function calls. In modern React (17+), JSX compiles using the automatic JSX transform:

// What you write:
const element = <h1 className="title">Hello World</h1>;

// What it compiles to:
import { jsx as _jsx } from 'react/jsx-runtime';
const element = _jsx('h1', { className: 'title', children: 'Hello World' });

React uses JSX because it combines UI markup and rendering logic in one place, enhancing readability, developer experience (DX), and enabling compile-time syntax checks.

3. Explain the difference between controlled and uncontrolled components.

  • Controlled Components: The form input data is handled by React state. React serves as the “single source of truth” for the input values, updating the state on every keystroke.
  • Uncontrolled Components: The form data is handled directly by the browser DOM. React accesses the DOM node when needed (usually on submission) using useRef.

Controlled Component Example:

import { useState } from 'react';

function ControlledInput() {
  const [value, setValue] = useState('');

  return (
    <input 
      type="text" 
      value={value} 
      onChange={(e) => setValue(e.target.value)} 
    />
  );
}

Uncontrolled Component Example:

import { useRef } from 'react';

function UncontrolledInput() {
  const inputRef = useRef(null);

  const handleSubmit = (e) => {
    e.preventDefault();
    alert(`Input Value: ${inputRef.current.value}`);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input type="text" ref={inputRef} />
      <button type="submit">Submit</button>
    </form>
  );
}

Verdict: Controlled components are preferred for most real-world scenarios because they enable instant field validation, dynamic disabling of submit buttons, and enforce predictable state.

4. What are React keys and why are they important?

Keys are unique, stable strings assigned to elements in a list. They serve as an identity card for React’s reconciliation engine.

<ul>
  {items.map((item) => (
    <li key={item.id}>{item.name}</li>
  ))}
</ul>

Why they are critical:

  • Reconciliation Guide: When list items change, are reordered, or deleted, keys tell React which specific element in the real DOM should be updated, kept, or destroyed.
  • Indices as Keys Warning: Using the array index (key={index}) as a key is an anti-pattern for dynamic lists. If list items are reordered, filtered, or inserted at the top, React will mismatch the DOM state with the component instances, causing visual glitches and rendering bugs.

5. What is prop drilling and how do you avoid it?

Prop drilling occurs when data is passed from a parent component down through multiple layers of nested child components that do not actually need the data, simply to deliver it to a deeply nested target component.

How to avoid it:

  1. Component Composition: Pass components or children down directly, keeping the state logic in the parent:

    <ParentLayout>
      <DeepChild user={user} />
    </ParentLayout>
    
  2. React Context API: Ideal for global, low-frequency state updates like themes, user authentication, or language settings.

  3. State Management Libraries: Using lightweight global stores like Zustand, Redux Toolkit, or Jotai.

6. Explain the React component lifecycle in functional components.

In modern functional React, lifecycle methods are replaced by Hooks (primarily useEffect):

  • Mounting: The component is rendered for the first time.

    useEffect(() => {
      console.log('Component mounted');
      // API calls, event listeners setup
    }, []); // Empty dependency array
    
  • Updating: The state or props of the component change, triggering a re-render.

    useEffect(() => {
      console.log('Dependency changed, component updated');
    }, [dependency]); // Runs on mount and whenever the dependency changes
    
  • Unmounting: The component is removed from the DOM.

    useEffect(() => {
      return () => {
        console.log('Component will unmount');
        // Clean up subscriptions, intervals, event listeners
      };
    }, []);
    

7. What is the difference between state and props?

Feature State Props
Definition Component’s local, internal memory Data passed down from a parent component
Mutability Mutable (via updater function like setState) Immutable (read-only within the receiver)
Scope Local to the component that defined it Dynamic; controlled by the parent component
Re-render Triggers component and children to re-render Triggers component and children to re-render

8. How does React handle events differently from vanilla JavaScript?

React abstracts standard DOM events through SyntheticEvents:

  1. Cross-Browser Compatibility: React wraps native browser events in a SyntheticEvent instance, ensuring that event properties behave identically across Chrome, Safari, Firefox, and Edge.
  2. CamelCase Naming: Event handlers are named using camelCase (e.g., onClick, onSubmit) rather than lowercase (e.g., onclick, onsubmit).
  3. Event Delegation: Instead of attaching event handlers to individual DOM nodes, React attaches a single listener to the root container of your application (#root) and bubbles events up for maximum memory efficiency.

⚓ Hooks Deep Dive (Questions 9-15)

9. Explain useState vs useReducer. When would you use each?

  • useState is designed for managing simple, independent state variables (numbers, strings, booleans, or shallow objects).
  • useReducer is suited for complex state trees, multi-step forms, or state where the next state depends heavily on the previous state.

useReducer Example:

const reducer = (state, action) => {
  switch (action.type) {
    case 'INCREMENT': return { count: state.count + 1 };
    case 'DECREMENT': return { count: state.count - 1 };
    default: return state;
  }
};

const [state, dispatch] = useReducer(reducer, { count: 0 });

Rule of Thumb: Use useReducer when you have 3+ interrelated state variables or when your state-updating logic starts leaking out of your component and needs to be isolated for clean testing.

10. What are the rules of hooks?

React enforces two strict rules for all Hooks:

  1. Only call Hooks at the top level: Do not call Hooks inside loops, conditional statements, or nested functions. This ensures React maps state variables in the exact same call sequence on every re-render.
  2. Only call Hooks from React functions: Call them from React functional components or custom hooks. Do not call Hooks from regular JavaScript utility functions.

Tip: Enable eslint-plugin-react-hooks in your project to automatically catch rule violations at compile time.

11. How does useEffect cleanup work?

The cleanup function is the function returned from inside your useEffect callback. It executes:

  1. Immediately before the effect re-runs (to clean up residual side effects from the previous render).
  2. When the component unmounts from the DOM.
useEffect(() => {
  const handleResize = () => console.log(window.innerWidth);
  window.addEventListener('resize', handleResize);

  // Cleanup Function:
  return () => {
    window.removeEventListener('resize', handleResize);
  };
}, []);

Failing to clean up event listeners, WebSocket channels, or polling intervals results in memory leaks that degrade web application performance.

12. What is useMemo and when should you use it?

useMemo caches (memoizes) the computed result of an expensive calculation, recalculating it only when its specified dependency values change.

const heavyCalculation = useMemo(() => {
  return performComplexSort(dataSet);
}, [dataSet]); // Recalculates only when dataSet changes

When to use it:

  • Heavy Computations: CPU-intensive tasks like sorting large arrays, parsing complex JSON structures, or rendering complex mathematical graphs.
  • Referential Equality: Caching object references passed down as props to children to prevent child re-renders.
  • Warning: Do not wrap every variable in useMemo. Overusing it adds garbage collection and dependency check overhead, which can actually slow down simple rendering.

13. Explain useCallback and its relationship with React.memo.

  • useCallback memoizes the function reference itself, preventing it from being re-created on every single render.
  • React.memo is a higher-order component that prevents a child component from re-rendering unless its props change.
// Parent Component
const handleButtonClick = useCallback(() => {
  console.log("Clicked!");
}, []); // Reference remains identical across renders

return <MemoizedButton onClick={handleButtonClick} />;

Without useCallback, a standard child component wrapped in React.memo will still re-render on every parent change because inline functions are re-created with new memory references on every tick, failing shallow prop equality checks.

14. What is useRef and how does it differ from useState?

  • useRef returns a mutable object whose .current property persists throughout the lifecycle of the component. Crucially, updating .current does not trigger a component re-render.
  • useState preserves state values across renders and explicitly triggers a re-render whenever the state updater function is called.
// useRef: Perfect for capturing DOM references directly
const inputElement = useRef(null);
const focusInput = () => inputElement.current.focus();

Use useRef when storing values that don’t affect the visual output of the UI (e.g., keeping track of timer intervals, click counts, or previous state references).

15. How do you create a custom hook? Give an example.

A custom hook is a standard JavaScript function whose name starts with “use” and which calls other React hooks internally. It allows you to extract complex stateful logic out of your UI components.

Reusable Custom Fetch Hook Example:

import { useState, useEffect } from 'react';

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let active = true;
    setLoading(true);

    fetch(url)
      .then((res) => res.json())
      .then((data) => {
        if (active) {
          setData(data);
          setLoading(false);
        }
      });

    return () => {
      active = false; // Aborts stale responses
    };
  }, [url]);

  return { data, loading };
}

⚡ Performance & Advanced (Questions 16-21)

16. How do you optimize React app performance?

When discussing optimizations, focus on these five core pillars:

  1. Code Splitting: Dynamic importing of routing pages using React.lazy() and <Suspense />.
  2. Memoization: Strategic application of React.memo(), useCallback(), and useMemo() to halt unnecessary renders.
  3. List Virtualization: Utilizing libraries like react-window or react-virtualized to only render the visible DOM nodes of a 10,000+ item list.
  4. Debouncing / Throttling: Delaying heavy search API calls or scroll listeners.
  5. State Colocation: Moving local states as close as possible to the components that actually use them to avoid sweeping parent-child re-render chains.

17. What is code splitting and how does React implement it?

Code splitting is the practice of breaking down a large, single production JavaScript bundle into multiple smaller chunks that are loaded dynamically (on-demand) as the user navigates your application.

import { lazy, Suspense } from 'react';

// Heavy component loaded lazily:
const DashboardChart = lazy(() => import('./DashboardChart'));

function App() {
  return (
    <Suspense fallback={<div>Loading Dashboard Chart...</div>}>
      <DashboardChart />
    </Suspense>
  );
}

This reduces the initial page load time, maximizing core web vitals and overall page speeds.

18. Explain React’s reconciliation algorithm.

The reconciliation algorithm determines how React updates the real DOM. Instead of comparing whole trees with O(n³) complexity, React uses heuristic rules to run at O(n) speeds:

  1. Different Element Types: If two elements have different types (e.g., swapping a <div> for a <section>), React tears down the entire subtree and mounts a completely new one from scratch.
  2. Same Element Types: If two elements have the same type, React compares the attributes/props, updates only the changed attributes, and recursively moves down to diff the children.
  3. Keys Strategy: When diffing children, React relies on keys to quickly match items in the old list with items in the new list, minimizing DOM rearrangements.

19. What are React Server Components (RSC)?

React Server Components (RSC) are a new architectural paradigm where components can render entirely on the server.

  • Server Components (Default): Render on the server, have zero impact on the client-side bundle size, can directly fetch database query results or secure API keys, and cannot use client hooks like useState or useEffect.
  • Client Components (Opt-in via "use client"): Hydrated on the client-side, fully interactive, and are free to use hooks, event listeners, and standard browser APIs.

RSCs allow you to build complex web apps with minimal client JavaScript, faster page load speeds, and improved SEO.

20. What is Suspense and how is it used in React 19?

Suspense is a built-in React component that lets you declaratively handle loading states across your application. It acts as an orchestrator, catching promises thrown by children fetching data or code-splitting.

In React 19, Suspense is deeply integrated with the framework:

  • It supports streaming server-side rendering (SSR), sending static sections to the browser immediately and hydration-streaming dynamic sections as data fetches resolve.
  • It handles resource loading (stylesheets, scripts, fonts) seamlessly, ensuring components render only after critical dependencies are ready in the browser.

21. Explain React’s error boundaries.

An Error Boundary is a React component that catches JavaScript errors anywhere in its child component tree, logs the crash diagnostics, and displays a backup fallback UI instead of letting the entire web page crash blank.

Note: Error boundaries must currently be written as class components, as functional hooks do not support componentDidCatch or getDerivedStateFromError yet.

import React from 'react';

class ErrorBoundary extends React.Component {
  state = { hasError: false };

  static getDerivedStateFromError(error) {
    return { hasError: true }; // Updates state to render fallback UI
  }

  componentDidCatch(error, errorInfo) {
    console.error("ErrorBoundary caught an error:", error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h2>Oops! Something went wrong.</h2>;
    }
    return this.props.children;
  }
}

🗃️ State Management & Patterns (Questions 22-25)

22. Compare Context API, Redux, and Zustand.

  • Context API: Built directly into React. Excellent for static or low-frequency updates (e.g., dark mode, user session). However, it is not optimized for rapid updates, as any value change forces all consumer components downstream to re-render.
  • Redux Toolkit: The classic, battle-tested standard. Strong ecosystem, middleware integration, and top-tier debugging (DevTools). However, it can be boilerplate-heavy for simple states.
  • Zustand: A modern, ultra-lightweight state manager. Based on a publish-subscribe model, it has zero boilerplate, does not wrap your app in providers, and allows hooks-based selective consumption, preventing unwanted renders.

23. What is the React Compiler (React Forget)?

The React Compiler is a new build-time tool introduced in React 19.

Traditionally, developers had to manually optimize components using useMemo, useCallback, and React.memo to avoid wasteful re-renders. The React Compiler automatically compiles your component tree at build time, injecting auto-memoization logic where it identifies that outputs won’t change.

This brings the performance of hand-optimized code to standard React code without manual hook pollution.

24. Explain the render props pattern vs custom hooks.

  • Render Props Pattern: Sharing code by passing a function as a prop, letting the receiving component decide how to render details:

    <MouseTracker render={(mouse) => (
      <h1>Mouse position is: {mouse.x}, {mouse.y}</h1>
    )} />
    
  • Custom Hooks: Extracts stateful logic directly into clean functions, avoiding component nesting (“wrapper hell”):

    const { x, y } = useMousePosition();
    return <h1>Mouse position is: {x}, {y}</h1>;
    

Verdict: While render props are still useful for layout components, custom hooks are the standard choice for sharing stateful business logic.

25. How do you handle authentication in a React app?

Authentication typically follows this robust pattern:

  1. State Storage: Store the user session/JWT token in a secure global state (like a Context Provider or Zustand store).
  2. API Requests: Attach the JWT token inside HTTP headers (using an Axios Request Interceptor) for all outgoing requests.
  3. Route Protection: Wrap sensitive dashboard routes inside a ProtectedRoute component:

    function ProtectedRoute({ children }) {
      const { isAuthenticated } = useAuth();
      return isAuthenticated ? children : <Navigate to="/login" />;
    }
    
  4. Token Refreshing: Set up interceptors to automatically listen for 401 Unauthorized statuses and refresh expired tokens securely in the background.

🎯 Elevate Your Frontend Prep with MockExperts

Reading the answers is just the first step. To pass high-stakes frontend interview loops, you need to communicate these concepts clearly under pressure.

Try MockExperts’ proctored AI Interview Simulator to practice live coding, dynamic React 19 system design, and behavioural rounds with instant, actionable feedback.

👉 Practice a Free Mock Interview now: MockExperts.com