Frontend Interview Tasks — React / TypeScript / Next.js

Target role: Mid-level Frontend Engineer | Duration: 90 min (pick 4–5 tasks)


Difficulty Overview

# Task Difficulty
1 Fix a bug Easy
2 Search input with typing delay Medium
3 Prevent unnecessary re-renders Medium
4 Designing a reusable Select component Medium
5 Discriminated unions for async state Medium
6 useReducer for a notification system Medium
7 Accessible modal Medium
8 Next.js Server Components inside Client Components Medium
9 Infinite scroll with Intersection Observer Medium–Hard
10 Error Boundary Medium–Hard
11 Context performance problem Hard

Task 1 — Fix a Bug

"This counter should increment by 1 every second. It works once and then stops. What's wrong? Fix it."

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // reads count from initial render closure
    }, 1000);
    return () => clearInterval(id);
  }, []); // count never in deps

  return <p>Count: {count}</p>;
}

Task 2 — Search Input with Typing Delay

"Build a search input that calls onSearch only after the user stops typing for 500 ms. Then extract that timing logic into a reusable custom hook."


Task 3 — Prevent Unnecessary Re-renders

"Every Item in this list re-renders whenever any item is deleted, even items that didn't change. Why? Fix it."

function Parent() {
  const [items, setItems] = useState(['apple', 'banana', 'cherry']);

  const handleDelete = (item: string) => {
    setItems(prev => prev.filter(i => i !== item));
  };

  return (
    <ul>
      {items.map(item => (
        <Item key={item} name={item} onDelete={handleDelete} />
      ))}
    </ul>
  );
}

function Item({ name, onDelete }: { name: string; onDelete: (s: string) => void }) {
  console.log(`render: ${name}`);
  return <li>{name} <button onClick={() => onDelete(name)}>×</button></li>;
}

Task 4 — Designing a Reusable Select Component

"You need to build a Select component that can be reused across the app with different data shapes — sometimes a list of users, sometimes a list of countries, sometimes something else entirely. How would you design its API so it doesn't need to know anything about the object shape it receives?"


Task 5 — Discriminated Unions for Async State

"You're storing async state with three separate booleans: loading, error, and data. What is the problem with this approach? Refactor it using a TypeScript discriminated union."

// Before — problematic
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<User | null>(null);

Task 6 — useReducer for a Notification System

"Build a notification system using useReducer. Notifications have an id, message, and type (success | error | info). They should auto-dismiss after 3 seconds. Expose add and dismiss functions."


Task 7 — Accessible Modal

"This modal works visually but has several accessibility problems. List them and fix as many as you can."

function Modal({ isOpen, onClose, children }) {
  if (!isOpen) return null;
  return (
    <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,.5)' }}>
      <div style={{ background: '#fff', padding: 24 }}>
        {children}
        <button onClick={onClose}>Close</button>
      </div>
    </div>
  );
}

Task 8 — Server Components inside Client Components

"ThemeWrapper needs useState so it must be a Client Component. UserProfile fetches directly from the database so it must stay a Server Component. The current code silently breaks that rule. Explain what's wrong and fix it."

// UserProfile.tsx — intended to be a Server Component
async function UserProfile({ userId }: { userId: string }) {
  const user = await db.users.findById(userId); // direct DB call — server only
  return (
    <div>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </div>
  );
}

// ThemeWrapper.tsx — Client Component
'use client';
import { useState } from 'react';
import UserProfile from './UserProfile';

export default function ThemeWrapper({ userId }: { userId: string }) {
  const [theme, setTheme] = useState<'light' | 'dark'>('light');

  return (
    <div data-theme={theme}>
      <button onClick={() => setTheme(t => t === 'light' ? 'dark' : 'light')}>
        Toggle theme
      </button>
      <UserProfile userId={userId} />
    </div>
  );
}

// page.tsx
import ThemeWrapper from './ThemeWrapper';

export default function Page() {
  return <ThemeWrapper userId="123" />;
}

Task 9 — Infinite Scroll with Intersection Observer

"The code below has three bugs spread across the hook and the component. The list either loads forever, loads the same page repeatedly, or crashes silently depending on which bugs trigger first. Find all three and fix them. Then place the sentinel element correctly."

// Simulates a paginated API — do not modify
async function fetchPage(page: number): Promise<string[]> {
  await new Promise(r => setTimeout(r, 600));
  if (page >= 5) return [];
  return Array.from({ length: 10 }, (_, i) => `Item ${page * 10 + i + 1}`);
}

// ---- fix the bugs in this hook ----
function useInfiniteScroll(onLoadMore: () => void, enabled: boolean) {
  const sentinelRef = useRef<HTMLDivElement>(null);

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

    const observer = new IntersectionObserver(
      ([entry]) => { if (entry.isIntersecting) onLoadMore(); }
    );
    observer.observe(el);
  }, []);

  return sentinelRef;
}

function ItemList() {
  const [items, setItems] = useState<string[]>([]);
  const [page, setPage] = useState(0);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);

  async function loadMore() {
    if (loading || !hasMore) return;
    setLoading(true);
    const next = await fetchPage(page);
    if (next.length === 0) {
      setHasMore(false);
    } else {
      setItems(prev => [...prev, ...next]);
      setPage(p => p + 1);
    }
    setLoading(false);
  }

  const sentinelRef = useInfiniteScroll(loadMore, hasMore);

  return (
    <div>
      {items.map(item => <div key={item} style={{ padding: 12 }}>{item}</div>)}
      {loading && <p>Loading...</p>}
      {/* TODO: place the sentinel ref here — think about when it should be rendered */}
    </div>
  );
}

Task 10 — Error Boundary

"A third-party component sometimes throws during render. Wrap it so the rest of the page still works and a friendly fallback is shown instead. The fallback should include a 'Try again' button."


Task 11 — Context Performance Problem

"This app stores user and theme in a single context. Components that only care about the user still re-render every time the theme changes. Diagnose the problem and fix it without reaching for an external library."

type AppCtx = { user: User; theme: string; setTheme: (t: string) => void };
const AppContext = createContext<AppCtx | null>(null);

function App() {
  const [user] = useState<User>({ name: 'Alice' });
  const [theme, setTheme] = useState('light');
  return (
    <AppContext.Provider value={{ user, theme, setTheme }}>
      <UserCard />
      <ThemeToggle />
    </AppContext.Provider>
  );
}