Three teams, three tables
Your company has an orders table, a staff table and a menu table. The design-system team built a <Table> component with sorting and filtering — and every team wanted different markup: cards on phones, sticky headers, a checkbox column. The component grew props until nobody dared change it. This is the moment teams discover the headless component: ship the behaviour as a hook and let each team render whatever it likes.
TanStack Table, Downshift and React Aria all work this way. Today you'll build a small version: useTable sorts, filters and gives you prop getters for your headers and buttons, while a presentational DataTable renders it. It pulls together everything in this unit — custom hooks, presentational components, derived state and inversion of control.
Behaviour in, markup out
The hook owns two pieces of state — the sort ({ key, dir } or null) and the filter text — and derives the visible rows from them with useMemo. It never renders a single element. Callers get back the rows plus functions that produce the right props for their own elements.
function useTable(rows, { columns, initialSort = null }) {
const [sort, setSort] = useState(initialSort);
const [filter, setFilter] = useState("");
const visible = useMemo(() => {
const q = filter.trim().toLowerCase();
const kept = q ? rows.filter((r) => columns.some((c) => String(r[c.key]).toLowerCase().includes(q))) : rows;
if (!sort) return kept;
const sign = sort.dir === "asc" ? 1 : -1;
return [...kept].sort((a, b) => sign * compare(a[sort.key], b[sort.key]));
}, [rows, columns, sort, filter]);
// …sortBy, getHeaderProps, getSortButtonProps
}Compare numbers as numbers: sorting [180, 60, 250] as strings gives 180, 250, 60. Check typeof and fall back to localeCompare for text, which also handles accents and different alphabets correctly.