Back to React
React
easy
mid

How would you build a React list with collapse and expand functionality?

Common build. Store expanded state as a Set<id> for O(1) toggle. Each row reads its expanded state and renders children when open. For 'only one open at a time' use a single id. For animation, use height: auto with transform tricks or framer-motion's AnimatePresence. Avoid storing expanded state inside the row component if you need controlled state from the parent.

6 min read·~25 min to think through

Bread-and-butter live coding question.

Independent collapse/expand

tsx
import { useState } from 'react';

type Item = { id: string; title: string; body: string };

function CollapsibleList({ items }: { items: Item[] }) {
  const [expanded, setExpanded] = useState<Set<string>>(new Set());

  const toggle = (id: string) => {
    setExpanded(prev => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });
  };

  return (
    <ul>
      {items.map(item => (
        <li key={item.id}>
          <button
            aria-expanded={expanded.has(item.id)}
            onClick={() => toggle(item.id)}
          >
            {item.title}
          </button>
          {expanded.has(item.id) && <div>{item.body}</div>}
        </li>
      ))}
    </ul>
  );
}

Accordion (one at a time)

tsx
function Accordion({ items }: { items: Item[] }) {
  const [openId, setOpenId] = useState<string | null>(null);
  return (
    <ul>
      {items.map(item => {
        const isOpen = openId === item.id;
        return (
          <li key={item.id}>
            <button
              aria-expanded={isOpen}
              onClick={() => setOpenId(isOpen ? null : item.id)}
            >
              {item.title}
            </button>
            {isOpen && <div>{item.body}</div>}
          </li>
        );
      })}
    </ul>
  );
}

Memoized row + stable callbacks

For long lists, memoize the row and pass a stable toggle:

tsx
const Row = memo(function Row({
  item, expanded, onToggle,
}: { item: Item; expanded: boolean; onToggle: (id: string) => void }) {
  return (
    <li>
      <button aria-expanded={expanded} onClick={() => onToggle(item.id)}>
        {item.title}
      </button>
      {expanded && <div>{item.body}</div>}
    </li>
  );
});

Accessibility

  • aria-expanded on the trigger.
  • aria-controls pointing to the panel id.
  • Keyboard: Enter/Space toggles; for accordions, Arrow keys move between headers.

Animation

Use a library — animating height in pure CSS is notoriously tricky.

tsx
import { AnimatePresence, motion } from 'framer-motion';

{isOpen && (
  <AnimatePresence>
    <motion.div
      initial={{ height: 0, opacity: 0 }}
      animate={{ height: 'auto', opacity: 1 }}
      exit={{ height: 0, opacity: 0 }}
    >
      {item.body}
    </motion.div>
  </AnimatePresence>
)}

Nested trees (file explorer)

For tree-shaped data, recurse:

tsx
function Tree({ node, expanded, toggle }: any) {
  const isOpen = expanded.has(node.id);
  return (
    <div>
      <button onClick={() => toggle(node.id)}>
        {isOpen ? 'open' : 'closed'} {node.name}
      </button>
      {isOpen && node.children?.map((c: any) => (
        <div style={{ marginLeft: 16 }} key={c.id}>
          <Tree node={c} expanded={expanded} toggle={toggle} />
        </div>
      ))}
    </div>
  );
}

What interviewers look for

  1. Stable keys (item.id, not index).
  2. State held at the right level (parent if a sibling needs it).
  3. Accessibility (aria-expanded at minimum).
  4. Reasonable performance posture (memo + stable callbacks).
  5. Clean toggle logic (Set or single-id, no nested mutation).

Follow-up questions

  • How would you persist expanded state across page reloads?
  • How would you make this controlled vs uncontrolled?
  • What ARIA attributes are needed for a proper accordion?

Common mistakes

  • Toggling expanded state by mutating the existing Set — React won't detect the change.
  • Storing expanded as a boolean inside each row — sibling rows can't read it.
  • Animating height in pure CSS by setting height: auto — doesn't transition.

Performance considerations

  • Set lookup is O(1). For 10k+ items, memoize the row component so a toggle only re-renders the affected row. For deeply nested trees, lazy-render children — don't compute the full subtree if collapsed.

Edge cases

  • Filtering or sorting the list while items are expanded — preserve the Set across renders.
  • Very large lists with all expanded — performance hit; virtualize.
  • Keyboard navigation for nested trees — arrow keys must traverse parent/child.

Real-world examples

  • VSCode file explorer, Notion's nested blocks, Trello board collapse, Slack channel sections, every FAQ page. Radix UI's Accordion is the standard headless reference.

Senior engineer discussion

Senior framing: this question tests state-shape choice (Set vs single id vs boolean-per-row), API design (controlled vs uncontrolled), and a11y awareness. Build the simple version first, then explain how you'd extend for animation, persistence, controlled mode.

Related questions