If you're building responsive React apps and you are using the TanStack Table v8 library, you might have noticed something: There’s no native way to collapse hidden columns on smaller screens.

By default, TanStack Table creates a horizontal scrollbar when columns overflow, and in many UX cases, this isn't ideal.

There's even an open discussion about this feature on the official repo: TanStack Table GitHub Discussion #3259

In fact, that GitHub issue is what inspired me to write my first article. I wanted to show a full working implementation of how this can be done today.

So, instead of relying on scrollbars, we are going to make a custom implementation inspired on the DataTables Responsive Extension, where hidden columns collapse into expandable sub-rows.


🎯 The Goal

We'll implement a responsive feature where:

  • Visible columns adjust dynamically based on screen width.
  • Hidden columns collapse into a toggleable subrow.
  • No horizontal scrolling needed.

The idea is to hide the last columns of the table based on the width of the window or screen, and then place these hidden data as subrows below the visible columns, just as DataTables does.


📺 Preview

You can see a live version of what we are going to make here:

👉 See Live on Vercel


🚀 Let's Build It

We'll create a simple Vite + React app to demonstrate this.

Initialize a new Vite React project:

npm create vite@latest tanstack-responsive-table
cd tanstack-responsive-table
npm install
npm install @tanstack/react-table

Once the project is set up, create a new folder called "components" inside the /src directory, and add a new file named Table.tsx. This is where we'll build our basic TanStack table.

If you checked the DataTables link earlier, we’ll use the same mock data for this tutorial.

In Table.tsx, copy and paste the following starter code:

// Table.tsx
import React from "react";
import {
  ColumnDef,
  flexRender,
  getCoreRowModel,
  useReactTable,
} from "@tanstack/react-table";

// Define your data type
type Person = {
  firstName: string;
  lastName: string;
  position: string;
  office: string;
  age: number;
  startDate: string;
  salary: string;
  ext: string;
  email: string;
};

// Your table data
const data: Person[] = [
  { firstName: "Airi", lastName: "Satou", position: "Accountant", office: "Tokyo", age: 33, startDate: "2008-11-28", salary: "$162,700", ext: "5407", email: "a.satou@datatables.net" },
  { firstName: "Angelica", lastName: "Ramos", position: "Chief Executive Officer (CEO)", office: "London", age: 47, startDate: "2009-10-09", salary: "$1,200,000", ext: "5797", email: "a.ramos@datatables.net" },
  { firstName: "Ashton", lastName: "Cox", position: "Junior Technical Author", office: "San Francisco", age: 66, startDate: "2009-01-12", salary: "$86,000", ext: "1562", email: "a.cox@datatables.net" },
  { firstName: "Bradley", lastName: "Greer", position: "Software Engineer", office: "London", age: 41, startDate: "2012-10-13", salary: "$132,000", ext: "2558", email: "b.greer@datatables.net" },
  { firstName: "Brenden", lastName: "Wagner", position: "Software Engineer", office: "San Francisco", age: 28, startDate: "2011-06-07", salary: "$206,850", ext: "1314", email: "b.wagner@datatables.net" },
  { firstName: "Brielle", lastName: "Williamson", position: "Integration Specialist", office: "New York", age: 61, startDate: "2012-12-02", salary: "$372,000", ext: "4804", email: "b.williamson@datatables.net" },
  { firstName: "Bruno", lastName: "Nash", position: "Software Engineer", office: "London", age: 38, startDate: "2011-05-03", salary: "$163,500", ext: "6222", email: "b.nash@datatables.net" },
  { firstName: "Caesar", lastName: "Vance", position: "Pre-Sales Support", office: "New York", age: 21, startDate: "2011-12-12", salary: "$106,450", ext: "8330", email: "c.vance@datatables.net" },
  { firstName: "Cara", lastName: "Stevens", position: "Sales Assistant", office: "New York", age: 46, startDate: "2011-12-06", salary: "$145,600", ext: "3990", email: "c.stevens@datatables.net" },
  { firstName: "Cedric", lastName: "Kelly", position: "Senior Javascript Developer", office: "Edinburgh", age: 22, startDate: "2012-03-29", salary: "$433,060", ext: "6224", email: "c.kelly@datatables.net" },
];

// Define your columns
const columns: ColumnDef<Person>[] = [
  { accessorKey: "firstName", header: "First Name" },
  { accessorKey: "lastName", header: "Last Name" },
  { accessorKey: "position", header: "Position" },
  { accessorKey: "office", header: "Office" },
  { accessorKey: "age", header: "Age" },
  { accessorKey: "startDate", header: "Start Date" },
  { accessorKey: "salary", header: "Salary" },
  { accessorKey: "ext", header: "Ext" },
  { accessorKey: "email", header: "Email" },
];

export const Table = () => {
  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  return (
    <div>
      <table border={1} cellPadding={5} cellSpacing={0}>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id}>
                  {flexRender(header.column.columnDef.header, header.getContext())}
                th>
              ))}
            tr>
          ))}
        thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                td>
              ))}
            tr>
          ))}
        tbody>
      table>
    div>
  );
};

You can export the Person type into a types folder inside /src, and move the mock data to a mocks folder (e.g., /src/mocks/data.ts) for better project organization.

Perfect! Now we have our mock data, column definitions, and a basic TanStack table. Next, let's make it responsive.

We'll achieve this by setting breakpoints and toggling the visibility of certain columns based on the screen width. (For example, you might choose to show only 3, 5, or 7 columns at different breakpoints — but feel free to adjust these numbers depending on your needs.)

To toggle column visibility, we'll use the Column Visibility Toggle APIs from TanStack, specifically the column.toggleVisibility method.

Let’s add this functionality to our component:

export const Table = () => {
  const [windowWidth, setWindowWidth] = useState(window.innerWidth);

  const table = useReactTable({
    data,
    columns,
    getCoreRowModel: getCoreRowModel(),
  });

  // Breakpoints
  const mediumBreakpoint = 640;
  const largeBreakpoint = 1024;
  const extraLargeBreakpoint = 1280;

  const updateColumnVisibility = () => {
    const columns = table.getAllColumns();
    columns.forEach((column, index) => {
      if (windowWidth < mediumBreakpoint) {
        column.toggleVisibility(index < 4);
      } else if (windowWidth < largeBreakpoint) {
        column.toggleVisibility(index < 6);
      } else if (windowWidth < extraLargeBreakpoint) {
        column.toggleVisibility(index < 8);
      } else {
        column.toggleVisibility(true);
      }
    });
  };

  useEffect(() => {
    updateColumnVisibility();
  }, [windowWidth]);

  useEffect(() => {
    const handleResize = () => setWindowWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    return () => window.removeEventListener("resize", handleResize);
  }, []);

  return (...);
};

Now, we also need to adjust the way the table renders.

Instead of showing all cells in a row, we’ll:

  • Only render visible cells normally.
  • Provide an expand/collapse button to reveal hidden columns as a sub-row.

First, let's update our column definitions to include an expander column:

const columns: ColumnDef<Person>[] = [
  {
    id: "expander",
    header: () => null,
    cell: ({ row }) =>
      hasHiddenColumns(row) ? (
        <button onClick={() => toggleRow(row.id)}>
          {expandedRows[row.id] ? "➖" : "➕"}
        button>
      ) : null,
    enableSorting: false,
    enableHiding: false,
  },
  { accessorKey: "firstName", header: "First Name" },
  { accessorKey: "lastName", header: "Last Name" },
  { accessorKey: "position", header: "Position" },
  { accessorKey: "office", header: "Office" },
  { accessorKey: "age", header: "Age" },
  { accessorKey: "startDate", header: "Start Date" },
  { accessorKey: "salary", header: "Salary" },
  { accessorKey: "ext", header: "Ext" },
  { accessorKey: "email", header: "Email" },
];

You could replace the ➕➖ with icons for a slightly more polished UI.

Next, update the return() of the Table component to handle expanded rows:

return (
    <div>
      <table border={1} cellPadding={5} cellSpacing={0}>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th key={header.id}>
                  {flexRender(header.column.columnDef.header, header.getContext())}
                th>
              ))}
            tr>
          ))}
        thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <React.Fragment key={row.id}>
              <tr>
                {row.getVisibleCells().map((cell) => (
                  <td key={cell.id}>
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  td>
                ))}
              tr>
              {expandedRows[row.id] && (
                <tr>
                  <td />
                  <td colSpan={row.getVisibleCells().length - 1}>
                    <div>
                      {row.getAllCells()
                        .filter((cell) => !cell.column.getIsVisible())
                        .map((cell) => (
                          <div key={cell.id}>
                            <strong>{cell.column.columnDef.header}:strong>{" "}
                            {flexRender(cell.column.columnDef.cell, cell.getContext())}
                          div>
                        ))}
                    div>
                  td>
                tr>
              )}
            React.Fragment>
          ))}
        tbody>
      table>
    div>
  );

Finally, let’s add two helper functions, hasHiddenColumns() and toggleRow(), to manage row expansion:

const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});

const toggleRow = (rowId: string) => {
  setExpandedRows((prev) => ({
    ...prev,
    [rowId]: !prev[rowId],
  }));
};

const hasHiddenColumns = (row: Row<Person>) => {
  return row.getAllCells().some((cell) => !cell.column.getIsVisible());
};

You now have your own custom responsive TanStack Table with collapsible columns! If you'd like to see the complete code, feel free to check out my repository here:

👉 https://github.com/juancruzroldan95/tanstack-table-responsive.


💬 Final Thoughts

This approach allows you to bring real responsiveness to TanStack Table without needing external plugins or waiting for official support.

It keeps the UX clean and mobile-friendly while fully leveraging TanStack Table’s API and React rendering flexibility.


If you found this useful, leave a ❤️ or comment below!

Happy coding! 🚀