Index

Passing data, context and routing

Four ways data moves, and they are asked in escalating order: down through props, up through a callback, sideways by lifting state to a common parent, and across by context. The trap is jumping to context too early - the first fix for prop drilling is composition, passing the rendered element down as children instead of the data it needs, and almost nobody says it. Routing is on this page for the same reason: the URL is state that lives outside React, and every router hook exists to turn a piece of it into something a component can read.

The four directions

Down is props: a parent renders a child with values. Up is a callback prop: the parent passes a function, the child calls it, the parent's state changes. There is no other channel - React's data flow is one-way by design, and a child can never reach into a parent.

Sideways is the same two moves composed. Two siblings that need the same value both get it as a prop from their closest common parent, and whichever one changes it does so through a callback that parent passed down. That is lifting state up, and it is still the first answer.

Across is context, and it is for when the common parent turns out to be the app root and the value would have to be threaded through eight components that do not care about it. Context is a transport for values that are read widely and change rarely; it is not a state manager and it has no selectors.

Prop drilling
Passing a prop through components that do not use it, purely to reach one that does. The cost is not performance - it is that every intermediate component's signature now mentions something it has nothing to do with, so renaming the value touches eight files.
Composition, the fix people forget
Instead of passing user down three levels so level three can render <Avatar user={user} />, render <Avatar user={user} /> at the top and pass it down as children. The intermediate components now take a children they render blindly and know nothing about users. No context, no library.
Context
One provider holds a value; any descendant reads it with useContext. Every consumer re-renders when the provider's value changes by identity - all of them, whether or not the part they read changed. That is the cost, and it is why an object literal as the value is a bug.

Questions

Answer each one yourself before you open it.

  1. 01How does a parent pass data to a child?

    Props. <Child user={user} />, read as props.user or destructured in the signature. They are read-only inside the child - a component must never assign to its own props.

    Read-only is what makes the data flow traceable: if a value is wrong, it came from exactly one place, the parent that rendered you. React freezes props in development so an assignment fails loudly rather than producing a component whose behaviour depends on render order.

    Follow-up What is children?

    Just a prop, filled in with whatever you nested between the tags. <Card><p>hi</p></Card> is <Card children={<p>hi</p>} />, and that equivalence is the whole basis of the composition pattern.

  2. 02How does a child send data up to a parent?

    The parent passes a function down and the child calls it with the value: <Picker onPick={setColour} />, and inside, onPick('blue'). The parent owns the state; the child only reports events.

    This is why the prop is named onSomething and the handler handleSomething - the child is announcing that something happened, not commanding the parent. Keeping that framing stops children from knowing what the parent will do about it, which is what makes them reusable.

  3. 03Two sibling components need the same value. What do you do?

    Lift the state to their closest common parent. The parent holds it, passes it down to both, and passes a setter down to whichever one changes it.

    There is no sibling channel in React and there does not need to be one - a shared value has a natural home, which is the lowest node that can see both readers. Putting it any higher makes more of the tree re-render than needs to; putting it lower makes it unreachable.

    Follow-up What if the common parent is the app root?

    That is the signal to look at context or a store - or to restructure with composition so the value does not have to travel that far.

  4. 04What is prop drilling, and what are the three fixes?

    Threading a prop through components that do not use it just to reach one that does. Fixes: composition - pass the rendered element down as children so the middle layers never see the data; context, for values read widely and changed rarely; or a store like Zustand or Redux when the state is large and interconnected.

    Take them in that order. Composition costs nothing, needs no library and often removes the problem entirely. Context is next and its cost is re-render breadth. A store is last and buys you selectors, devtools and middleware - pay for it when you need those, not to avoid passing two props.

    Follow-up Is prop drilling always bad?

    No. Two or three levels of an explicit prop is clearer than a context you have to go and find the provider for. It becomes a problem at depth, and when the middle layers are supposed to be generic.

  5. 05Show me the composition fix.

    Instead of <Layout user={user} /> passing user to <Sidebar> passing it to <Avatar>, write <Layout sidebar={<Avatar user={user} />} />. Layout and Sidebar now render a slot they know nothing about, and user never left the component that had it.

    An element is just a value, so you can create it where the data is and render it where the layout is. This is what the React docs call inversion of control, and it is the reason a well-designed component library takes children and slots rather than twenty configuration props.

  6. 06What are the three pieces of context?

    const ThemeContext = createContext(defaultValue) creates it; <ThemeContext.Provider value={...}> supplies a value to a subtree; useContext(ThemeContext) reads the nearest provider above the reader.

    Nearest matters - providers nest, and an inner one shadows an outer one for its own subtree. That is how a light page can contain a dark panel with no special handling.

  7. 07What is createContext's default value for?

    It is what useContext returns when there is no matching provider above the component. It is not the initial value of the provider - a provider with a value always wins.

    Which is why a plausible-looking default is a trap: forget the provider and everything renders with silently wrong data instead of failing. Pass undefined as the default and let your custom hook throw.

  8. 08Write a useTheme that throws outside its provider. Why bother?

    const ctx = useContext(ThemeContext); if (ctx === undefined) throw new Error('useTheme must be used inside a <ThemeProvider>'); return ctx; - because the alternative failure is Cannot read properties of undefined, forty components deep, with no clue what is missing.

    It also hides the context object itself, so nothing outside the module can consume it directly and the provider stays the only way in. That means you can change context to a store later and no consumer changes. Two lines, and it is the detail that makes an interviewer nod.

  9. 09What is context's performance cost?

    Every component that calls useContext for that context re-renders whenever the provider's value changes by identity - including components that only read one field of it and only care about a field that did not change. React.memo does not stop it.

    There is no subscription granularity: context propagates a single value, and consumers cannot say which part of it they depend on. That is the structural difference from a store like Redux or Zustand, where a selector means a component only re-renders when its slice changes.

    Follow-up What's the classic mistake?

    <Ctx.Provider value={{ user, setUser }}>. That object literal is a new identity every render of the provider, so every consumer re-renders on every parent render even when user never changed. Wrap it in useMemo.

  10. 10How do you fix context re-renders?

    Memoise the value object; split one context into several so a fast-changing value does not drag a slow-changing one along; split state from dispatch into two contexts, since dispatch never changes; and move the state down so fewer components are under the provider at all.

    Splitting state and dispatch is the highest-value one and the least known: components that only fire actions subscribe to the dispatch context, which never changes identity, so they never re-render on a state change. Beyond that, wrapping expensive consumers in React.memo helps their children, and a selector library exists precisely because context cannot do it natively.

  11. 11Is context a replacement for Redux?

    No. Context is a transport - it solves passing a value down without drilling. Redux is a store: one place to update from, selectors so components subscribe to slices, middleware, devtools and time travel. Context has none of that.

    The honest version of the answer is that most apps' 'global state' was server state, and once that moves to a data library the rest is small enough for context plus useState, or a small store. Say that and the question is finished.

  12. 12What does BrowserRouter do?

    It puts the History API's state into React context, so the router hooks and <Routes> have something to read, and it listens for back/forward so a browser navigation re-renders the tree. It wraps the app once, at the top.

    HashRouter is the alternative for static hosts that cannot rewrite unknown paths to index.html - it keeps the route after a #, which the server never sees. In a data-router setup you use createBrowserRouter plus RouterProvider, which is what unlocks loaders and actions.

  13. 13<Link> versus an <a>?

    <Link to="/about"> renders an anchor but intercepts the click and pushes to the history stack, so React re-renders the matched route with no network request. A plain <a href> does a full page load - the whole bundle re-downloads and every piece of state is gone.

    It still renders a real <a> with a real href, which is why middle-click, ctrl-click and 'open in new tab' work and why crawlers and screen readers see a link. Use a real <a> deliberately for anything outside the app.

    Follow-up And <NavLink>?

    A Link that knows whether it is the active route: its className and style can be functions receiving { isActive, isPending }, so you style the current nav item without comparing paths by hand.

  14. 14How do you navigate from code?

    const navigate = useNavigate(), then navigate('/dashboard'). navigate('/login', { replace: true }) replaces the current entry instead of pushing, and navigate(-1) goes back.

    replace matters after a redirect or a successful login: without it the user presses back and lands on the page you just bounced them off, which bounces them forward again. Navigating in render is a bug - do it in an event handler or an effect, or render <Navigate> instead.

  15. 15useParams - and what type do you get?

    It returns the dynamic segments of the matched path as an object of strings. For path="/users/:userId" at /users/42, useParams().userId is the string "42", never the number.

    The string is the bug people ship: id === 42 is false and an equality check against numeric data from an API fails silently. Convert once at the boundary and, if it must be numeric, validate it - a URL is user input.

  16. 16How do you read and write the query string?

    const [searchParams, setSearchParams] = useSearchParams(). It is a real URLSearchParams, so searchParams.get('q') reads - returning null when absent - and setSearchParams({ q: 'react', page: '2' }) writes and navigates.

    Putting filters, the page number and the search term in the URL rather than in state is what makes them shareable, bookmarkable and survivable across a refresh. Everything comes back as a string or null, so give each one a default at the point of reading.

  17. 17What are nested routes and what is <Outlet>?

    A parent <Route> with child routes inside it. The parent renders shared chrome - nav, sidebar - and puts <Outlet /> where the matched child should appear. The URL nests the same way the components do.

    The win is that the layout does not unmount when you move between children, so scroll position, open menus and any state in the shell survive the navigation. <Route index element={...} /> is the child that matches the parent's path exactly, which is how /dashboard renders something rather than an empty outlet.

  18. 18How do you pass data through a link without putting it in the URL?

    <Link to="/detail" state={{ from: location }} />, read on the other side with useLocation().state. It rides in the history entry, not the URL.

    The caveat is the whole answer: it is gone if the user opens that URL directly, refreshes into it, or shares it - history state is per-entry, not part of the address. So it is fine for a hint like 'where they came from' and wrong for anything the page needs in order to render. Anything load-bearing goes in the path or the query string.

  19. 19How do you write a catch-all 404 route?

    <Route path="*" element={<NotFound />} /> as a child of your top-level <Routes>.

    Order does not matter in React Router v6 - it ranks all the routes by specificity and picks the best match, rather than taking the first that matches. So * can sit anywhere in the list, which is different from v5 and from Express, and worth saying because it is a common trick question.

  20. 20How do you redirect / to a default page?

    <Route index element={<Navigate to="/dashboard" replace />} />. <Navigate> performs the navigation as an effect of being rendered, and replace keeps the redirect out of the history stack.

    Without replace the back button returns to /, which immediately redirects forward again - the user is trapped. That symptom is the giveaway that someone forgot it.

  21. 21Write a protected route.

    A component that reads auth state and returns one of three things: a loading state while the session is being checked, <Navigate to="/login" replace state={{ from: location }} /> when there is no user, and <Outlet /> otherwise. Wrap the private routes in it as a layout route.

    The loading branch is the one people miss, and skipping it is a real bug: on the first render the session has not been restored yet, so user is null and the app bounces an authenticated user to the login screen on every refresh. Saving location in the navigation state is what lets login send them back where they were meant to be.

    Follow-up Is this security?

    No. It is routing. Anyone can edit the bundle or call the API directly, so every protected endpoint must check authorisation on the server. The route guard is UX.

  22. 22What was withRouter, and what replaced it?

    A v5 higher-order component that injected match, location and history as props, needed because class components could not use hooks. useParams, useLocation and useNavigate replaced it.

    It is a useful thing to be able to name because it explains the shape of a lot of older code: HOCs existed to get context-like values into components that had no other way to read them. Hooks made the wrapper unnecessary and removed a layer from the tree.

  23. 23What is useLocation for?

    Reading the current location - pathname, search, hash, state, key. It is what you use to scroll to top on navigation, to fire an analytics event per route, and to remember where a redirect came from.

    It also re-renders the component on every navigation, which makes it the standard way to run something on route change: an effect depending on location.pathname.

  24. 24What is a portal, and what is it for?

    createPortal(children, domNode) renders children into a DOM node outside the parent's DOM subtree, while keeping them in the React tree where they were written. Modals, tooltips, dropdowns and toasts.

    The reason is CSS, not architecture: an ancestor with overflow: hidden, a transform, or a lower z-index stacking context will clip or bury a modal no matter what you set on it. Rendering into document.body escapes all of that without moving the component.

  25. 25Do events bubble out of a portal?

    Yes - through the REACT tree, not the DOM tree. A click inside a portal rendered into document.body still fires the onClick of the React component that rendered the portal.

    React's synthetic events propagate along the component hierarchy, and the portal's children are still children in that hierarchy. It is usually what you want - a form inside a modal still reaches the form's handlers - and occasionally a surprise, when an outside-click handler on an ancestor fires for clicks that were visually outside it.

  26. 26What does a modal need beyond the portal?

    role="dialog" with aria-modal="true" and a label, focus moved into the dialog on open and restored to the trigger on close, Escape to close, a click on the backdrop to close, and the background scroll locked. A focus trap so Tab cannot leave it.

    Without focus management a keyboard user's focus is still behind the modal - they tab through a page they cannot see. Restoring focus to the element that opened it is the half everyone forgets, and it is one ref plus a cleanup. The native <dialog> element with showModal() gives you the trap, the backdrop and Escape for free and is the better answer in new code.

  27. 27What is forwardRef and when do you need it?

    Refs are not props, so a ref put on a function component does not reach anything by default. forwardRef((props, ref) => ...) accepts it as a second argument so you can attach it to a DOM node inside - which any reusable input, button or field wrapper needs.

    Without it you get "Function components cannot be given refs" and ref.current is null, so the parent cannot focus your input or scroll to it. In React 19 this changed: ref is a normal prop on function components and forwardRef is no longer required - but every existing codebase is full of it, so know both.

    Follow-up And useImperativeHandle?

    It customises what the parent receives - expose { focus, clear } instead of the raw node, so the parent gets a small API rather than the whole DOM element.

  28. 28How does a recursive component terminate?

    On a base case in the data, not in the component: a leaf node has no children to map over, so the recursion has nothing to render and stops. In a file tree, isFolder: false returns a plain row and never recurses.

    The other thing to check is that the data really is a tree. A cyclic structure - a node reachable from its own descendant - recurses until the stack blows, so anything from user input needs a depth cap or a visited set.

  29. 29In a recursive tree, where should the expanded state live?

    In each node component, if collapsing a folder is allowed to forget what was open inside it. In a single map or Set held by the root, if the tree needs to expand to a path, restore from a URL, or remember state across a collapse.

    Per-node useState is less code and it is what an interviewer expects first - but it has a visible consequence worth naming: conditionally rendering the children unmounts them, so their state resets. Say that out loud and offer the lifted version as the alternative; that exchange is usually the whole point of the question.

  30. 30Why not use the array index as a key?

    Because the key is the element's identity for reconciliation. With index keys, deleting or reordering an item means React matches the wrong old element to the wrong new one - so DOM state like input contents, focus and scroll position stays with the position rather than the item.

    The bug is specific and memorable: a list of inputs, you type in the second one, then delete the first - and your text is now in the row that moved up. Index keys are safe only when the list is never reordered, filtered or added to except at the end.

    Follow-up Where does the key come from in a tree?

    Something stable and unique among siblings. A name works when siblings cannot repeat; a full path from the root is safer and doubles as an id for a lifted expanded-set.

  31. 31children, a render prop, or a component prop - how do you choose?

    children for content the parent does not need to control. A render prop - a function as a prop, or as children - when the wrapper has data the caller needs in order to render. A component prop when the caller supplies a whole named slot.

    Render props were the pre-hooks way to share stateful logic, and custom hooks replaced most of them: <MouseTracker>{({x, y}) => ...}</MouseTracker> became const {x, y} = useMouse(). What is left for them is genuinely rendering-related - a virtualised list handing you an index and a style.

  32. 32What does React.memo do, and why does it so often not work?

    It skips re-rendering a component when its props are shallow-equal to last time. It fails when a prop is an inline object, array or arrow function, because those are a new identity every parent render and shallow-equal is false.

    So <Child onClick={() => save(id)} /> inside a memoised child is a memo that never hits. The fix is useCallback for the handler and useMemo for the object - which is the real reason those hooks exist. And memo cannot help at all when the re-render came from context or from the component's own state.

  33. 33When a parent re-renders, do all its children re-render?

    By default yes - React re-runs the whole subtree. It does not re-render children that were passed in as children from a grandparent, because those elements were created higher up and their identity did not change.

    That last part is a genuinely useful trick and the second reason to reach for composition: moving expensive subtrees to be children of the component with the fast-changing state means they are created outside it and skipped, with no memo anywhere. Re-render also does not mean DOM work - React still diffs, and an unchanged subtree touches nothing.

  34. 34What is the compound component pattern?

    A parent and a set of children that share implicit state through context - <Tabs><Tabs.List><Tabs.Tab/></Tabs.List><Tabs.Panel/></Tabs>. The caller controls layout; the parent controls which tab is selected.

    It is what you offer instead of a component with fifteen configuration props. The state lives in the parent and travels by context, so the pieces can be nested and reordered freely without the caller wiring anything up.

  35. 35Where does state actually belong?

    As low in the tree as it can go while still being visible to everything that needs it. Push it down until something breaks, then stop.

    State high in the tree re-renders everything under it and makes components harder to reuse. The two forces are lifting (so siblings can share) and pushing down (so renders stay narrow), and the right answer is the lowest node that satisfies the first.

Predict the output

The output is hidden. Predict it before you open it.

Context with no provider above it

const ThemeContext = createContext("light");

function Badge() {
  console.log(useContext(ThemeContext));
  return null;
}

// rendered on its own
root.render(<Badge />);

// then rendered inside a provider
root.render(
  <ThemeContext.Provider value="dark">
    <Badge />
  </ThemeContext.Provider>
);
Show the output
light
dark

The createContext argument is the fallback for a consumer with no provider above it - not the provider's initial value. A plausible default like this means forgetting the provider fails silently, which is why the better pattern is createContext(undefined) plus a hook that throws.

The context value that changes every render

const Ctx = createContext(undefined);

const Deep = memo(function Deep() {
  const { user } = useContext(Ctx);
  console.log("Deep render");
  return <span>{user.name}</span>;
});

function App({ user }) {
  const value = { user, isAdmin: user.role === "admin" };
  return (
    <Ctx.Provider value={value}>
      <Deep />
    </Ctx.Provider>
  );
}

// mounted, then App re-renders twice with the SAME user object
Show the output
Deep render
Deep render
Deep render

value is a fresh object every render, so context sees a changed value and every consumer re-renders - and memo cannot stop it, because the re-render did not come from props. useMemo(() => ({ user, isAdmin }), [user]) fixes it and the output drops to one line.

Events out of a portal

function App() {
  return (
    <div onClick={() => console.log("parent div")}>
      {createPortal(
        <button onClick={() => console.log("button")}>x</button>,
        document.body
      )}
    </div>
  );
}

// the button - which is a child of document.body in the DOM - is clicked
Show the output
button
parent div

React events propagate through the React tree, not the DOM tree, so the portal's content still bubbles to the component that rendered it. Useful when a modal's form needs the parent's handlers; surprising when an outside-click handler fires for a click that was visually inside the modal.

What useParams gives you

// <Route path="/users/:userId" element={<User />} />
// the URL is /users/42

function User() {
  const { userId } = useParams();
  console.log(userId, typeof userId, userId === 42, Number(userId) === 42);
  return null;
}
Show the output
42 string false true

Route params are always strings - the URL has no types. userId === 42 is false against numeric data from an API, and nothing throws, so the row just never matches. Convert once where you read it.

Reading the query string

// the URL is /search?q=react&page=2

const [params] = useSearchParams();

console.log(params.get("q"));
console.log(params.get("page"), typeof params.get("page"));
console.log(params.get("sort"));
console.log(params.get("sort") ?? "newest");
Show the output
react
2 string
null
newest

URLSearchParams.get returns null for a missing key, not undefined - so ?? and || both work but a === undefined check does not. Everything comes back as a string, page numbers included.

A ref on a function component

function Field(props) {
  return <input {...props} />;
}

function App() {
  const ref = useRef(null);
  useEffect(() => console.log("ref.current is", ref.current), []);
  return <Field ref={ref} />;
}
Show the output
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()?
ref.current is null

ref is not a prop in React 18 and below, so it never reaches the input and the parent cannot focus it. Wrapping Field in forwardRef and attaching the second argument to the <input> fixes it. React 19 makes ref a normal prop and the warning is gone.

State is preserved by position, not by name

function Counter({ label }) {
  const [n, setN] = useState(0);
  console.log("render", label, n);
  return <button onClick={() => setN(n + 1)}>{label}</button>;
}

function App({ swap }) {
  return swap ? <Counter label="B" /> : <Counter label="A" />;
}

// mounted with swap=false, the button clicked twice, then swap flips to true
Show the output
render A 0
render A 1
render A 2
render B 2

Same component type in the same position, so React reuses the instance and its state survives the swap - the count is still 2 under a different label. Adding key={swap ? "b" : "a"} makes them different elements, the old one unmounts, and the last line becomes render B 0. That is the whole key-to-reset trick.

memo defeated by an inline arrow

const Child = memo(function Child({ onPick }) {
  console.log("Child render");
  return <button onClick={() => onPick("blue")}>pick</button>;
});

function Parent() {
  const [n, setN] = useState(0);
  return (
    <div>
      <button onClick={() => setN(n + 1)}>{n}</button>
      <Child onPick={(c) => console.log("picked", c)} />
    </div>
  );
}

// mounted, then the counter button clicked twice
Show the output
Child render
Child render
Child render

The arrow passed as onPick is a new function on every render of Parent, so the shallow prop comparison always fails and memo never skips anything. Wrapping it in useCallback with [] deps drops this to a single line.

Callback prop, in one direction

function Picker({ onPick }) {
  return <button onClick={() => onPick("blue")}>blue</button>;
}

function Parent() {
  const [colour, setColour] = useState(null);
  console.log("parent renders with", colour);
  return <Picker onPick={setColour} />;
}

// mounted, then the button clicked twice
Show the output
parent renders with null
parent renders with blue

The child never holds the value - it reports an event and the parent decides. The second click logs nothing because state is set to the same string, so React bails out of re-rendering after comparing with Object.is.

Walking the tree, in render order

const folderStructureData = {
  name: "root",
  isFolder: true,
  items: [
    { name: "index.html", isFolder: false },
    {
      name: "app",
      isFolder: true,
      items: [
        { name: "app.js", isFolder: false },
        {
          name: "src",
          isFolder: true,
          items: [{ name: "main.jsx", isFolder: false }],
        },
      ],
    },
  ],
};

function walk(node, depth = 0) {
  console.log("  ".repeat(depth) + node.name);
  (node.items || []).forEach((child) => walk(child, depth + 1));
}

walk(folderStructureData);
Show the output
root
  index.html
  app
    app.js
    src
      main.jsx

Depth-first, parent before children - the same order a recursive component renders in. The base case is in the data, not the function: a leaf has no items, so the loop body never runs and the recursion stops on its own.

Route order in React Router v6

function About() {
  console.log("About");
  return <h1>About</h1>;
}
function NotFound() {
  console.log("NotFound");
  return <h1>404</h1>;
}

// the URL is /about
<Routes>
  <Route path="*" element={<NotFound />} />
  <Route path="/about" element={<About />} />
</Routes>;
Show the output
About

v6 ranks every route by specificity and renders the best match, so the catch-all can sit anywhere in the list. This is the trick-question version - in v5, and in Express, the first match would have won and this would render the 404.

Nesting providers

const Ctx = createContext("outer");

function Reader({ label }) {
  console.log(label, useContext(Ctx));
  return null;
}

root.render(
  <Ctx.Provider value="page">
    <Reader label="a" />
    <Ctx.Provider value="panel">
      <Reader label="b" />
    </Ctx.Provider>
    <Reader label="c" />
  </Ctx.Provider>
);
Show the output
a page
b panel
c page

useContext walks up and takes the nearest provider, so an inner one shadows an outer one for its own subtree only. That is how a light page contains a dark panel with no extra plumbing.

Children created by a grandparent do not re-render

function Slow() {
  console.log("Slow render");
  return <p>slow</p>;
}

function Shell({ children }) {
  const [n, setN] = useState(0);
  console.log("Shell render", n);
  return (
    <div>
      <button onClick={() => setN(n + 1)}>{n}</button>
      {children}
    </div>
  );
}

function App() {
  return (
    <Shell>
      <Slow />
    </Shell>
  );
}

// mounted, then Shell's button clicked twice
Show the output
Shell render 0
Slow render
Shell render 1
Shell render 2

<Slow /> was created in App, which did not re-render, so the element handed to Shell is the same object each time and React skips it. No memo anywhere - this is composition doing the optimisation, and it is the second reason to reach for children.

Write it from memory

Complete, runnable code. Practice typing it from memory before your interview.

Recursive collapsible file tree

The classic React coding-round question, on the exact data it is usually handed. Each node owns its own expand state, folders recurse and files do not, and the recursion terminates on the data rather than on a depth counter.

import { useState } from "react";

const folderStructureData = {
  name: "root",
  isFolder: true,
  items: [
    { name: "index.html", isFolder: false },
    {
      name: "app",
      isFolder: true,
      items: [
        { name: "app.js", isFolder: false },
        {
          name: "src",
          isFolder: true,
          items: [{ name: "main.jsx", isFolder: false }],
        },
      ],
    },
  ],
};

function Node({ node }) {
  const [expanded, setExpanded] = useState(false);

  if (!node.isFolder) {
    return <li className="file">{node.name}</li>;
  }

  const items = node.items || [];

  return (
    <li className="folder">
      <button
        type="button"
        aria-expanded={expanded}
        onClick={() => setExpanded((open) => !open)}
      >
        {expanded ? "-" : "+"} {node.name}
      </button>

      {expanded && (
        <ul>
          {items.map((child) => (
            <Node key={child.name} node={child} />
          ))}
        </ul>
      )}
    </li>
  );
}

export default function FileExplorer({ data = folderStructureData }) {
  return (
    <ul className="tree">
      <Node node={data} />
    </ul>
  );
}
  • The component calls itself. That is legal because by the time Node renders, the function declaration is complete - recursion in JSX needs no special handling.
  • The base case is isFolder: false: a file returns a row and never reaches the map, so the recursion stops on the shape of the data. No depth counter and no guard needed for a real tree.
  • Each Node calls its own useState, so every folder owns its own open flag. Say the consequence out loud before they ask: {expanded && ...} unmounts the children, so collapsing a folder forgets what was open inside it.
  • If that matters, lift it - one Set of expanded paths in FileExplorer, passed down with a toggle callback, and the node becomes stateless. That is the follow-up they are usually fishing for.
  • key={child.name} is fine here because siblings cannot share a name in a filesystem. A full path built from the parent (parentPath + "/" + child.name) is the safer general answer, and it is also the id you would key the lifted Set on.
  • aria-expanded on the toggle is a one-attribute win: a screen reader announces the folder as collapsed or expanded instead of reading a bare name.
  • Adding files and folders is the usual extension: keep the tree in state at the top, and pass an onAdd(path) callback down through the same recursion.

Protected routes, with the redirect back

The routing question that is really three questions: nested layout routes, the loading state before auth is known, and remembering where the user was going. All three are in twenty lines.

import {
  BrowserRouter,
  Routes,
  Route,
  Outlet,
  Navigate,
  NavLink,
  useLocation,
  useNavigate,
} from "react-router-dom";
import { useAuth } from "./auth";

function ProtectedRoute() {
  const { user, loading } = useAuth();
  const location = useLocation();

  if (loading) return <p role="status">Checking your session…</p>;

  if (!user) {
    return <Navigate to="/login" replace state={{ from: location }} />;
  }

  return <Outlet />;
}

function Layout() {
  return (
    <div>
      <nav>
        <NavLink to="/dashboard">Dashboard</NavLink>
        <NavLink to="/settings">Settings</NavLink>
      </nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

function Login() {
  const { signIn } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const from = location.state?.from?.pathname || "/dashboard";

  async function handleSubmit(e) {
    e.preventDefault();
    const form = new FormData(e.currentTarget);
    await signIn(form.get("email"), form.get("password"));
    navigate(from, { replace: true });
  }

  return (
    <form onSubmit={handleSubmit}>
      <label htmlFor="email">Email</label>
      <input id="email" name="email" type="email" />
      <label htmlFor="password">Password</label>
      <input id="password" name="password" type="password" />
      <button type="submit">Sign in</button>
    </form>
  );
}

export default function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Layout />}>
          <Route index element={<Navigate to="/dashboard" replace />} />
          <Route path="login" element={<Login />} />

          <Route element={<ProtectedRoute />}>
            <Route path="dashboard" element={<Dashboard />} />
            <Route path="settings" element={<Settings />} />
            <Route path="users/:userId" element={<UserDetail />} />
          </Route>

          <Route path="*" element={<NotFound />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}
  • ProtectedRoute is a layout route with no path of its own: it renders <Outlet /> for its children, so one wrapper guards a whole group instead of being repeated per route.
  • The loading branch is the bug everyone ships. Without it, the first render happens before the session is restored, user is null, and every refresh bounces an authenticated user to the login page.
  • replace on both navigations. On the guard it stops back-button ping-pong; after signing in it keeps the login page out of history.
  • state={{ from: location }} is how the login page knows where to return to. It rides in the history entry rather than the URL, which is exactly right here - it is a hint, not something the page needs to render.
  • <Route index> matches the parent's path exactly, which is how / gets a destination without a route named /.
  • None of this is security. The API must authorise every request; a guard in the bundle only decides what to draw.

A theme context and the hook that guards it

The context question, done the way that survives the follow-ups: undefined default, a hook that throws, a memoised value, and the context object never exported.

import {
  createContext,
  useContext,
  useState,
  useMemo,
  useCallback,
} from "react";

const ThemeContext = createContext(undefined);

export function ThemeProvider({ children, initialTheme = "light" }) {
  const [theme, setTheme] = useState(initialTheme);

  const toggle = useCallback(
    () => setTheme((t) => (t === "light" ? "dark" : "light")),
    []
  );

  const value = useMemo(
    () => ({ theme, setTheme, toggle }),
    [theme, toggle]
  );

  return (
    <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (context === undefined) {
    throw new Error("useTheme must be used inside a <ThemeProvider>");
  }
  return context;
}

// Using it
function ThemeToggle() {
  const { theme, toggle } = useTheme();
  return (
    <button type="button" onClick={toggle}>
      Switch to {theme === "light" ? "dark" : "light"}
    </button>
  );
}

export function App() {
  return (
    <ThemeProvider>
      <ThemeToggle />
    </ThemeProvider>
  );
}
  • createContext(undefined) plus the throw turns a missing provider into one clear error instead of Cannot read properties of undefined in a component that has nothing to do with the mistake.
  • ThemeContext is not exported. The hook is the only way in, so the provider cannot be bypassed and you can swap context for a store later without touching a single consumer.
  • useMemo on the value is the whole performance answer. Without it the object is a new identity on every render of whatever contains the provider, and every consumer re-renders.
  • toggle is useCallback with [] because the functional updater means it needs nothing from the render scope - so it stays out of the memo's dependency churn.
  • If the theme changed on every scroll event, the next move would be splitting it into two contexts: one for the value, one for the setters, since the setter context never changes identity.

A modal in a portal, with the focus handling

Portals are asked as "build a modal", and the portal is the easy half. What is being checked is whether you know that focus, Escape and scroll lock are part of the component.

import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";

export function Modal({ open, onClose, title, children }) {
  const dialogRef = useRef(null);
  const openerRef = useRef(null);

  useEffect(() => {
    if (!open) return;

    openerRef.current = document.activeElement;
    dialogRef.current.focus();

    function handleKeyDown(e) {
      if (e.key === "Escape") onClose();
    }

    document.addEventListener("keydown", handleKeyDown);
    const previousOverflow = document.body.style.overflow;
    document.body.style.overflow = "hidden";

    return () => {
      document.removeEventListener("keydown", handleKeyDown);
      document.body.style.overflow = previousOverflow;
      if (openerRef.current) openerRef.current.focus();
    };
  }, [open, onClose]);

  if (!open) return null;

  return createPortal(
    <div className="overlay" onClick={onClose}>
      <div
        className="dialog"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        ref={dialogRef}
        onClick={(e) => e.stopPropagation()}
      >
        <h2 id="modal-title">{title}</h2>
        {children}
        <button type="button" onClick={onClose}>
          Close
        </button>
      </div>
    </div>,
    document.body
  );
}

// Using it
export function DeleteButton({ onConfirm }) {
  const [open, setOpen] = useState(false);
  const close = useCallback(() => setOpen(false), []);

  return (
    <>
      <button type="button" onClick={() => setOpen(true)}>
        Delete
      </button>
      <Modal open={open} onClose={close} title="Delete this item?">
        <p>This cannot be undone.</p>
        <button
          type="button"
          onClick={() => {
            onConfirm();
            close();
          }}
        >
          Yes, delete it
        </button>
      </Modal>
    </>
  );
}
  • The portal exists for CSS: any ancestor with overflow: hidden, a transform or its own stacking context would clip or bury the dialog. Rendering into document.body escapes all of it while the component stays where it was written.
  • openerRef holds the element that had focus before the modal opened, and the cleanup puts focus back. That restore is the half everyone forgets and the one a keyboard user notices immediately.
  • onClick on the overlay closes it; stopPropagation on the dialog stops a click inside from bubbling out and closing it. React events bubble through the React tree even from a portal, which is what makes this work.
  • onClose is in the effect's dependencies, so an inline arrow at the call site would tear down and rebuild the listener every render - hence useCallback in the caller.
  • Not a full focus trap: Tab can still leave the dialog. Say so, and name the fix - cycle focus between the first and last focusable elements on Tab, or use the native <dialog> with showModal(), which gives you the trap, the backdrop and Escape for free.
← Fetching data, and the race conditionName the pattern →