Skip to the solution
MediumMachine Coding18 min read

Build a Todo App with Pagination, Filters & Optimistic Updates

266 views

The problem

Question presented to candidate: "Our todo list now lives on a server. Build the client: five todos per page with numbered pages, Prev and Next and ellipses, All / Active / Completed filters and a search box. Adding, ticking and deleting must feel instant, even though the API is slow and sometimes fails. When it fails, put things back and tell the user. Keep the page and filter in the URL so a refresh lands on the same view."

Requirements:

  • Server-side pagination: the API takes page, pageSize, filter and q and returns { items, total }. Simulate it in-file with setTimeout; no real network.
  • A pager with first and last page, the current page with one neighbour each side, … for gaps, and disabled Prev and Next at the ends.
  • Filters and a debounced search; changing either resets to page 1.
  • Optimistic add, toggle and delete: the UI changes immediately, the row shows it is saving, and a failed request rolls back only that change and shows an error message. A failed add gives the typed text back.
  • After a successful write, refetch the page to reconcile with the server; if the current page becomes empty, step back to the last page.
  • ?page=2&filter=active&q=... in the URL via history.replaceState, read back on load.
  • Ignore stale responses when requests overlap.

What a strong answer should cover:

  • A clear split between server state (the current page from the API) and UI state (page, filter, search text, draft, toast), with the total page count derived.
  • Optimistic updates with a rollback that is an inverse patch of one row, not a whole-list snapshot that could undo other in-flight changes.
  • Reconciliation after success (refetch), and why temporary ids are needed for an optimistic create.
  • Race safety: a cleanup flag or request id so a slow old response cannot overwrite a newer one; a debounce so search does not fire per keystroke.
  • The page-window algorithm and edge handling (page beyond the end, a gap of exactly one page).
  • Accessibility: aria-current="page", aria-pressed filters, an alert for failures, aria-busy while loading.

Clarifying questions expected:

  • "Is pagination done by the server or can I fetch everything and slice on the client?"
  • "When a write fails, should I retry automatically or just roll back and tell the user?"
  • "Where should a newly added todo appear if I am on page 3 or on the Completed filter?"

Code / implementation expected: Yes. A working client in React, Vue or Angular with a simulated API that can be made to fail on demand, about 200-250 lines.

  • React
  • Todo
  • Pagination
  • Optimistic UI

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

18 min read · 9 sections

React solution

Every file of the working solution. Open it in the playground to run and change it.

ReactComplete solution
import { useEffect, useRef, useState } from "react";
import { listTodos, createTodo, updateTodo, deleteTodo } from "./src/api/api";
import Pager from "./src/components/Pager";

const PAGE_SIZE = 5;
const FILTERS = ["all", "active", "completed"];

function readUrl() {
  const params = new URLSearchParams(window.location.search);
  const filter = params.get("filter");
  return {
    page: Math.max(1, Number.parseInt(params.get("page"), 10) || 1),
    filter: FILTERS.includes(filter) ? filter : "all",
    q: params.get("q") ?? "",
  };
}

export default function App() {
  const [initial] = useState(readUrl);
  const [page, setPage] = useState(initial.page);
  const [filter, setFilter] = useState(initial.filter);
  const [search, setSearch] = useState(initial.q); // what is typed
  // what was sent, after the debounce
  const [query, setQuery] = useState(initial.q);
  const [data, setData] = useState({ items: [], total: 0 });
  const [loading, setLoading] = useState(true);
  const [reloads, setReloads] = useState(0);
  const [draft, setDraft] = useState("");
  const [failNext, setFailNext] = useState(false);
  const [toast, setToast] = useState("");
  const tempIds = useRef(0);

  const totalPages = Math.max(1, Math.ceil(data.total / PAGE_SIZE));

  // Debounce the search box: send the query 300 ms after the last keystroke.
  useEffect(() => {
    if (search === query) return;
    const timer = setTimeout(() => {
      setQuery(search);
      setPage(1);
    }, 300);
    return () => clearTimeout(timer);
  }, [search, query]);

  // Fetch the current page. The ignore flag drops
  // responses that arrive after a newer request.
  useEffect(() => {
    let ignore = false;
    setLoading(true);
    listTodos({ page, pageSize: PAGE_SIZE, filter, q: query }).then((res) => {
      if (ignore) return;
      const last = Math.max(1, Math.ceil(res.total / PAGE_SIZE));
      if (page > last) return setPage(last); // the page emptied out: step back
      setData(res);
      setLoading(false);
    });
    return () => {
      ignore = true;
    };
  }, [page, filter, query, reloads]);

  // Keep the address bar in sync, without adding history entries.
  useEffect(() => {
    const params = new URLSearchParams();
    if (page > 1) params.set("page", String(page));
    if (filter !== "all") params.set("filter", filter);
    if (query) params.set("q", query);
    const qs = params.toString();
    window.history.replaceState(
      null,
      "",
      qs ? "?" + qs : window.location.pathname,
    );
  }, [page, filter, query]);

  const refresh = () => setReloads((n) => n + 1);
  const patchItems = (fn) => setData((d) => ({ ...d, items: fn(d.items) }));

  function takeFailure() {
    if (failNext) setFailNext(false);
    return failNext;
  }

  async function addTodo(event) {
    event.preventDefault();
    const text = draft.trim();
    if (!text) return;
    const fail = takeFailure();
    const tempId = "temp-" + ++tempIds.current;
    setToast("");
    setDraft("");
    // Optimistic: show the row now, marked as saving.
    setData((d) => ({
      items: [{ id: tempId, text, done: false, pending: true }, ...d.items],
      total: d.total + 1,
    }));
    try {
      await createTodo(text, { fail });
      refresh(); // reconcile with the server: real id, real order
    } catch {
      setData((d) => ({
        items: d.items.filter((t) => t.id !== tempId),
        total: d.total - 1,
      }));
      setDraft((current) => current || text); // give the text back
      setToast("Could not add “" + text + "”. Nothing was saved.");
    }
  }

  async function toggleTodo(todo) {
    const fail = takeFailure();
    const done = !todo.done;
    setToast("");
    patchItems((items) =>
      items.map((t) => (t.id === todo.id ? { ...t, done, pending: true } : t)),
    );
    try {
      await updateTodo(todo.id, { done }, { fail });
      refresh();
    } catch {
      // Roll back only this field on this row,
      // so other in-flight changes survive.
      patchItems((items) =>
        items.map((t) =>
          t.id === todo.id ? { ...t, done: !done, pending: false } : t,
        ),
      );
      setToast("Could not update “" + todo.text + "”. Change undone.");
    }
  }

  async function removeTodo(todo) {
    const fail = takeFailure();
    const index = data.items.findIndex((t) => t.id === todo.id);
    setToast("");
    setData((d) => ({
      items: d.items.filter((t) => t.id !== todo.id),
      total: d.total - 1,
    }));
    try {
      await deleteTodo(todo.id, { fail });
      refresh();
    } catch {
      setData((d) => {
        const items = [...d.items];
        items.splice(Math.min(index, items.length), 0, todo);
        return { items, total: d.total + 1 };
      });
      setToast("Could not delete “" + todo.text + "”. It is back.");
    }
  }

  function changeFilter(name) {
    setFilter(name);
    setPage(1);
  }

  return (
    <main className="app">
      <h1>Todos</h1>

      <form className="row" onSubmit={addTodo}>
        <input
          data-testid="new-todo"
          aria-label="New task"
          placeholder="Add a task"
          value={draft}
          onChange={(e) => setDraft(e.target.value)}
        />
        <button data-testid="add" type="submit">
          Add
        </button>
      </form>

      <div className="row">
        <input
          data-testid="search"
          type="search"
          aria-label="Search tasks"
          placeholder="Search"
          value={search}
          onChange={(e) => setSearch(e.target.value)}
        />
        <div className="filters" role="group" aria-label="Filter">
          {FILTERS.map((name) => (
            <button
              key={name}
              type="button"
              data-filter={name}
              aria-pressed={filter === name}
              onClick={() => changeFilter(name)}
            >
              {name}
            </button>
          ))}
        </div>
      </div>

      <label className="fail">
        <input
          type="checkbox"
          data-testid="fail-next"
          checked={failNext}
          onChange={(e) => setFailNext(e.target.checked)}
        />
        Make the next save fail
      </label>

      {toast && (
        <p role="alert" data-testid="toast" className="toast">
          {toast}{" "}
          <button
            type="button"
            aria-label="Dismiss"
            onClick={() => setToast("")}
          >
            ✕
          </button>
        </p>
      )}

      <p data-testid="summary" className="muted" aria-live="polite">
        {data.total} {data.total === 1 ? "task" : "tasks"} · page {page} of{" "}
        {totalPages}
        {loading ? " · loading…" : ""}
      </p>

      <ul className="list" aria-busy={loading}>
        {data.items.map((todo) => (
          <li
            key={todo.id}
            data-testid="todo"
            className={todo.pending ? "pending" : ""}
          >
            <label>
              <input
                type="checkbox"
                checked={todo.done}
                disabled={todo.pending}
                onChange={() => toggleTodo(todo)}
              />
              <span className={todo.done ? "text done" : "text"}>
                {todo.text}
              </span>
            </label>
            {todo.pending && <span className="saving">Saving…</span>}
            <button
              type="button"
              data-action="delete"
              aria-label={"Delete " + todo.text}
              disabled={todo.pending}
              onClick={() => removeTodo(todo)}
            >
              ✕
            </button>
          </li>
        ))}
      </ul>
      {!loading && data.items.length === 0 && (
        <p data-testid="empty" className="muted">
          No tasks match.
        </p>
      )}

      <Pager page={page} totalPages={totalPages} onChange={setPage} />
    </main>
  );
}

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 66 of 107 in Machine coding

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Build a Modal dialog

Continue