React Coding Interview Questions with Solutions

The components and hooks that come up in React machine coding rounds — debounced search, custom hooks, modals, infinite scroll, autocomplete — with worked solutions.

12 questions4 fresher, 5 mid-level, 3 senior

The machine coding round is usually 30 to 45 minutes to build one small component. Nobody expects a finished product. What is being marked is whether you clarify the requirements before typing, choose sensible state, handle the empty, loading and error cases, and clean up after yourself.

Two habits earn marks in every one of these: say what you are about to do before you do it, and mention the edge case even if you decide not to implement it. Each solution below notes what the interviewer is actually watching for.

1.Build a counter with increment, decrement and reset

Fresher

One piece of state and three handlers. Use the functional updater form, and keep the initial value in a constant so reset has something to return to.

function Counter({ initial = 0, step = 1, min = -Infinity, max = Infinity }) {
  const [count, setCount] = useState(initial);

  const change = (delta) =>
    setCount((current) => Math.min(max, Math.max(min, current + delta)));

  return (
    <div>
      <button onClick={() => change(-step)} disabled={count <= min}>-</button>
      <output>{count}</output>
      <button onClick={() => change(step)} disabled={count >= max}>+</button>
      <button onClick={() => setCount(initial)}>Reset</button>
    </div>
  );
}

Likely follow-up questions

  • What if two increments happen in the same tick?

2.Build a search input that debounces its API call

Mid-level

Keep the input controlled for instant feedback, debounce the value with a custom hook, and fire the request from an effect keyed on the debounced value with cancellation in the cleanup.

function useDebouncedValue(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const id = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(id);
  }, [value, delay]);
  return debounced;
}

function Search() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const debounced = useDebouncedValue(query);

  useEffect(() => {
    if (!debounced.trim()) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    search(debounced, { signal: controller.signal })
      .then(setResults)
      .catch((error) => {
        if (error.name !== 'AbortError') setResults([]);
      });

    return () => controller.abort();
  }, [debounced]);

  return (
    <input value={query} onChange={(e) => setQuery(e.target.value)} />
  );
}

Likely follow-up questions

  • What happens if the user clears the input mid-request?
  • Where would a loading state go?

3.Build a todo list with add, toggle and delete

Fresher

One array of objects in state, updated immutably. Give each item a real id rather than using the index, and use a form so Enter submits.

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [draft, setDraft] = useState('');

  function add(event) {
    event.preventDefault();
    const title = draft.trim();
    if (!title) return;
    setTodos((current) => [...current, { id: crypto.randomUUID(), title, done: false }]);
    setDraft('');
  }

  const toggle = (id) =>
    setTodos((current) =>
      current.map((todo) => (todo.id === id ? { ...todo, done: !todo.done } : todo))
    );

  const remove = (id) =>
    setTodos((current) => current.filter((todo) => todo.id !== id));

  return (
    <>
      <form onSubmit={add}>
        <input value={draft} onChange={(e) => setDraft(e.target.value)} />
        <button>Add</button>
      </form>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id}>
            <label>
              <input type='checkbox' checked={todo.done} onChange={() => toggle(todo.id)} />
              {todo.title}
            </label>
            <button onClick={() => remove(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
      {todos.length === 0 && <p>Nothing to do yet.</p>}
    </>
  );
}

Likely follow-up questions

  • How would you add a filter for completed items?

4.Write a custom useFetch hook

Mid-level

Hold data, error and status in one state object, fetch in an effect keyed on the URL, abort in the cleanup, and return the triple. Say out loud that in production you would use a query library.

function useFetch(url, options) {
  const [state, setState] = useState({ data: null, error: null, status: 'idle' });

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

    const controller = new AbortController();
    setState({ data: null, error: null, status: 'loading' });

    fetch(url, { ...options, signal: controller.signal })
      .then((response) => {
        if (!response.ok) throw new Error(`Request failed: ${response.status}`);
        return response.json();
      })
      .then((data) => setState({ data, error: null, status: 'success' }))
      .catch((error) => {
        if (error.name === 'AbortError') return;
        setState({ data: null, error, status: 'error' });
      });

    return () => controller.abort();
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [url]);

  return state;
}

Likely follow-up questions

  • Why is options not in the dependency array?
  • How would you add caching?

5.Implement a usePrevious hook

Mid-level

Store the value in a ref and update it in an effect. Because effects run after render, the ref still holds the previous render's value while the component renders.

function usePrevious(value) {
  const ref = useRef(undefined);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current; // the value from the last committed render
}

The whole point is the timing: the render reads ref.current before the effect overwrites it, so it sees the old value. On the first render it is undefined, which callers must handle.

Likely follow-up questions

  • Why a ref rather than state?
Practise this in the quiz

6.Build an accordion where only one section is open at a time

Fresher

Hold the id of the open section in state rather than a boolean per section, so exclusivity is structural. Toggle to null when the open one is clicked again.

function Accordion({ sections }) {
  const [openId, setOpenId] = useState(null);

  return (
    <div>
      {sections.map(({ id, title, body }) => {
        const isOpen = openId === id;
        return (
          <section key={id}>
            <h3>
              <button
                aria-expanded={isOpen}
                aria-controls={`panel-${id}`}
                onClick={() => setOpenId(isOpen ? null : id)}
              >
                {title}
              </button>
            </h3>
            <div id={`panel-${id}`} hidden={!isOpen}>{body}</div>
          </section>
        );
      })}
    </div>
  );
}

Likely follow-up questions

  • How would you allow multiple open sections?

7.Build a star rating component

Fresher

Render a radio group styled as stars, track the committed value in state and the hovered value separately, and display the hovered value when there is one. Radios give you keyboard support for free.

function Rating({ max = 5, value, onChange }) {
  const [hovered, setHovered] = useState(null);
  const shown = hovered ?? value;

  return (
    <fieldset onMouseLeave={() => setHovered(null)}>
      <legend>Rating</legend>
      {Array.from({ length: max }, (_, index) => index + 1).map((star) => (
        <label key={star} onMouseEnter={() => setHovered(star)}>
          <input
            type='radio'
            name='rating'
            value={star}
            checked={value === star}
            onChange={() => onChange(star)}
          />
          <span aria-hidden='true'>{star <= shown ? '\u2605' : '\u2606'}</span>
          <span className='sr-only'>{star} stars</span>
        </label>
      ))}
    </fieldset>
  );
}

Likely follow-up questions

  • How would you support half stars?

8.Build a pagination component

Mid-level

Take the current page, total pages and a change handler as props, keep the page in the URL rather than in local state, and render a window of page numbers with ellipses rather than every page.

function usePageWindow(current, total, span = 2) {
  return useMemo(() => {
    const pages = new Set([1, total]);
    for (let page = current - span; page <= current + span; page += 1) {
      if (page > 1 && page < total) pages.add(page);
    }
    return [...pages].sort((a, b) => a - b);
  }, [current, total, span]);
}

function Pagination({ current, total, onChange }) {
  const pages = usePageWindow(current, total);

  return (
    <nav aria-label='Pagination'>
      <button onClick={() => onChange(current - 1)} disabled={current === 1}>
        Previous
      </button>
      {pages.map((page, index) => (
        <span key={page}>
          {index > 0 && page - pages[index - 1] > 1 && <span>...</span>}
          <button
            aria-current={page === current ? 'page' : undefined}
            onClick={() => onChange(page)}
          >
            {page}
          </button>
        </span>
      ))}
      <button onClick={() => onChange(current + 1)} disabled={current === total}>
        Next
      </button>
    </nav>
  );
}

Likely follow-up questions

  • Why keep the page in the URL?

9.Implement infinite scroll

Senior

Put a sentinel element after the list and observe it with IntersectionObserver. When it becomes visible and you are not already loading, fetch the next page. Disconnect the observer in the effect cleanup.

function useInfiniteScroll(onLoadMore, { enabled = true } = {}) {
  const sentinelRef = useRef(null);
  const callbackRef = useRef(onLoadMore);

  useEffect(() => {
    callbackRef.current = onLoadMore;
  });

  useEffect(() => {
    const node = sentinelRef.current;
    if (!node || !enabled) return;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) callbackRef.current();
    }, { rootMargin: '200px' });

    observer.observe(node);
    return () => observer.disconnect();
  }, [enabled]);

  return sentinelRef;
}
  • rootMargin starts the fetch before the sentinel is actually visible, so the list rarely appears to stall.
  • The enabled flag is how you stop firing while a request is in flight or when there are no more pages.
  • The ref holding the latest callback keeps the observer from being torn down and recreated on every render.
  • For very long lists, combine it with virtualisation, or the DOM grows without limit.

Likely follow-up questions

  • Why not use a scroll event listener?

10.Build an accessible modal dialog

Senior

Render it through a portal, close on Escape and on backdrop click, trap focus inside while it is open, and return focus to the element that opened it on close.

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

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

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

    const onKeyDown = (event) => {
      if (event.key === 'Escape') onClose();
    };

    document.addEventListener('keydown', onKeyDown);
    document.body.style.overflow = 'hidden';

    return () => {
      document.removeEventListener('keydown', onKeyDown);
      document.body.style.overflow = '';
      openerRef.current?.focus();
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return createPortal(
    <div className='overlay' onClick={onClose}>
      <div
        ref={dialogRef}
        role='dialog'
        aria-modal='true'
        aria-label={title}
        tabIndex={-1}
        onClick={(event) => event.stopPropagation()}
      >
        {children}
      </div>
    </div>,
    document.body
  );
}

Likely follow-up questions

  • How would you implement the Tab cycle?
  • What does the native <dialog> element give you for free?
Practise this in the quiz

11.Build an autocomplete with keyboard navigation

Senior

Debounce the query, keep the suggestion list and a highlighted index in state, and handle ArrowUp, ArrowDown, Enter and Escape on the input. Wire up the combobox ARIA attributes so the highlighted option is announced.

function Autocomplete({ onSelect, fetchSuggestions }) {
  const [query, setQuery] = useState('');
  const [items, setItems] = useState([]);
  const [active, setActive] = useState(-1);
  const debounced = useDebouncedValue(query);
  const listId = useId();

  useEffect(() => {
    let cancelled = false;
    if (!debounced) return setItems([]);

    fetchSuggestions(debounced).then((next) => {
      if (!cancelled) {
        setItems(next);
        setActive(-1);
      }
    });

    return () => { cancelled = true; };
  }, [debounced, fetchSuggestions]);

  function onKeyDown(event) {
    if (event.key === 'ArrowDown') {
      event.preventDefault();
      setActive((index) => (index + 1) % items.length);
    } else if (event.key === 'ArrowUp') {
      event.preventDefault();
      setActive((index) => (index - 1 + items.length) % items.length);
    } else if (event.key === 'Enter' && active >= 0) {
      onSelect(items[active]);
      setQuery(items[active].label);
      setItems([]);
    } else if (event.key === 'Escape') {
      setItems([]);
    }
  }

  return (
    <div>
      <input
        role='combobox'
        aria-expanded={items.length > 0}
        aria-controls={listId}
        aria-activedescendant={active >= 0 ? `${listId}-${active}` : undefined}
        value={query}
        onChange={(event) => setQuery(event.target.value)}
        onKeyDown={onKeyDown}
      />
      <ul id={listId} role='listbox'>
        {items.map((item, index) => (
          <li
            key={item.id}
            id={`${listId}-${index}`}
            role='option'
            aria-selected={index === active}
            onMouseDown={() => onSelect(item)}
          >
            {item.label}
          </li>
        ))}
      </ul>
    </div>
  );
}

Likely follow-up questions

  • Why onMouseDown and not onClick?
  • How would you handle a slow response arriving after a newer one?

12.Implement a useLocalStorage hook

Mid-level

Wrap useState, read the stored value lazily on first render, write on change, and guard every access with try/catch because storage can be unavailable or hold invalid JSON.

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const stored = window.localStorage.getItem(key);
      return stored === null ? initialValue : JSON.parse(stored);
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    try {
      window.localStorage.setItem(key, JSON.stringify(value));
    } catch {
      // quota exceeded or storage disabled: fall back to memory only
    }
  }, [key, value]);

  return [value, setValue];
}

Likely follow-up questions

  • How would you make this work with server rendering?
  • How would you keep two tabs in sync?

All React interview questions