How would you build a data table with sorting & filtering in React?

Advanced20 min interview
Skills tested:
Data Transformation PipelinesClient-Side Performance OptimizationMemoization StrategiesTable State ManagementAccessibilityPagination and Filtering Logic

Advertisement

🧩 Scenario

Create a reusable data table component that supports: - Column sorting (asc/desc/none) - Global search (debounced) - Column filters (e.g., status, date range) - Pagination (client-side) - Performance optimizations (memoization, virtualization note) - Accessibility (keyboard focus, ARIA attributes)

🧠 Architecture Walkthrough

Why useMemo Is the Core of This Component

Every time state changes in the DataTable a sort toggle, a filter keystroke, a page change React will re-render. Without memoization, that means re-scanning and re-sorting the entire dataset on every render, including renders triggered by unrelated state updates like row selection.

The solution is to gate all expensive derivations behind useMemo with precise dependency arrays. In the demo, processedData depends only on data, debouncedGlobalSearch, debouncedFilters, and sortConfig.

This means changing the page number or selecting a row does not retrigger the filter and sort computation only actual changes to the data pipeline do. The second useMemo for paginatedData then slices the already-processed result, so pagination is O(1) regardless of dataset size.

The Pipeline Order: Filter Before Sort Before Paginate

The three operations must run in a fixed order: filter first, then sort, then paginate. Filtering reduces the working set, so sorting fewer items is faster. Pagination must always come last because it depends on totalItems the count after filtering to compute the correct page range and "showing X of Y" label.

If you paginate before filtering, page 2 of the unfiltered list might not exist in the filtered list, leaving you on an empty page with no way to know why. That is why the demo includes a useEffect that resets currentPage to 1 whenever the debounced search or filters change the currently visible page may no longer be valid after a filter narrows the result set.

Debouncing Search Without Debouncing State

A subtle but important decision in the demo is that globalSearch and filters are stored as raw, non-debounced state that updates on every keystroke. This keeps the input field responsive the user sees their text appear immediately.

Separately, debouncedGlobalSearch and debouncedFilters are derived using a custom useDebounce hook, and these debounced values are what the useMemo computation depends on.

The consequence is that the filtering pipeline only runs after the user stops typing for 300ms, but the input itself never feels laggy. If you put the debounce on the state setter instead, the input itself would feel frozen during fast typing a UX regression that many candidates miss when reasoning about debounce placement.

💡 Key Code Explained

const processedData = useMemo(() => {
  let filteredData = [...data];

  if (debouncedGlobalSearch) {
    const searchLower = debouncedGlobalSearch.toLowerCase();
    filteredData = filteredData.filter((item) =>
      Object.values(item).some((value) =>
        String(value).toLowerCase().includes(searchLower),
      ),
    );
  }

  Object.entries(debouncedFilters).forEach(([key, value]) => {
    if (value) {
      const column = columns.find((col) => col.key === key);
      if (column?.filterType === 'select') {
        filteredData = filteredData.filter((item) => item[key] === value);
      } else {
        const filterLower = value.toLowerCase();
        filteredData = filteredData.filter((item) =>
          String(item[key]).toLowerCase().includes(filterLower),
        );
      }
    }
  });

  if (sortConfig.key) {
    filteredData.sort((a, b) => {
      const aVal = a[sortConfig.key];
      const bVal = b[sortConfig.key];
      if (aVal === bVal) return 0;
      if (aVal === null || aVal === undefined) return 1;
      if (bVal === null || bVal === undefined) return -1;
      const comparison = aVal > bVal ? 1 : -1;
      return sortConfig.direction === 'desc' ? -comparison : comparison;
    });
  }

  return { data: filteredData, totalItems: filteredData.length };
}, [data, debouncedGlobalSearch, debouncedFilters, sortConfig]);

This is the entire data pipeline in a single useMemo. Notice that filteredData starts as a shallow copy of data the original prop is never mutated. The Object.values(item).some(...) pattern for global search is intentionally greedy: it casts every field to a string including booleans and numbers, so "true" or "50000" becomes searchable.

The sort handles null and undefined explicitly, always pushing them to the end regardless of direction omitting this causes unpredictable behavior when sparse data reaches a sort comparator. The dependency array does not include columns because columns are defined as a constant in the parent and never change but in a truly generic component you would add it.

const handleSort = (key) => {
  setSortConfig((prev) => {
    if (prev.key === key) {
      if (prev.direction === 'asc') {
        return { key, direction: 'desc' };
      } else if (prev.direction === 'desc') {
        return { key: null, direction: null };
      }
    }
    return { key, direction: 'asc' };
  });
};

This implements a three-state sort cycle: first click is ascending, second click is descending, third click clears the sort entirely. Using the functional updater form setSortConfig(prev => ...) is important here because handleSort is defined without useCallback in this demo, meaning it would close over a stale sortConfig if it read the value directly.

The functional form reads the latest state at the time of the update, avoiding the stale closure bug. The "none" state returning { key: null, direction: null } restores the original data order, which users expect without it, you can only toggle between ascending and descending, which frustrates users who want to return to the default order.

⚖️ Tradeoffs

ApproachProCon
Client-side filter + sort with useMemoZero server round-trips, instant UX for small/medium datasetsMemory scales with dataset size; re-derives on every filter change
Server-side filtering and sortingScales to millions of rows, server controls indexesEvery keystroke is a network request; requires debouncing and loading state
Virtualized rows (react-window)DOM size stays constant even with 100k rowsRequires fixed row heights or complex dynamic measurement

🎯 What Interviewers Actually Check

  • Mentions debouncing the search input without being prompted — and specifically that the debounce wraps the value feeding useMemo, not the input's onChange
  • Knows that pagination must come after filtering so that totalItems reflects the filtered count, not the original
  • Handles nulls and undefined in the sort comparator without crashing
  • Resets the current page to 1 when filters change, not just on initial load
  • Can explain why the processedData and paginatedData are two separate useMemo calls instead of one

❓ Follow-Up Questions

  1. Your sort comparator uses aVal > bVal for all types. What breaks when sorting date strings like "2025-01-15" — and what should you do instead?
  2. The global search uses Object.values(item).some(...) which includes every field. How do you give columns an opt-out so that ID or internal fields are never included in global search?
  3. How would you write a test for the processedData pipeline that verifies the filter-before-sort-before-paginate ordering?
  4. At 50,000 rows the useMemo recomputation takes 80ms, making the UI feel sluggish. What are your options for keeping the filter responsive?
  5. Your manager says pagination is confusing and wants infinite scroll instead. How does replacing pagination with IntersectionObserver change the data pipeline?

🎮 Live Demo

📝 Summary

The key architectural insight in a client-side data table is treating the visible rows as a pure derivation of state, not as independent state themselves. By composing useMemo into a filter-sort-paginate pipeline and applying debouncing to the values that feed it not the inputs themselves you get both responsiveness and performance without a server round-trip.

The three-state sort cycle and the page-reset-on-filter-change are small details that separate a polished implementation from one that frustrates users. For datasets beyond a few thousand rows, the same pipeline shapes apply on the server; the only difference is that useMemo becomes an API call and debouncing becomes a requirement rather than an optimization.

Frequently Asked Questions

Should I implement sorting & filtering on client or server?

For small datasets (<10k) client-side is fine. For large datasets or when using complex queries, do server-side sorting/filtering.

How to handle many columns and complex filters?

Provide column visibility toggles and a filter builder UI or use a server-side query builder.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement