Examples › Expanding rows

The rowExpansion property allows you to define the “row expansion” behavior of the DataTable.

Basic usage

In its most basic usage scenario, the feature only requires specifying the content to be lazily rendered when a row is expanded.

return (
  <DataTable
    withTableBorder
    withColumnBorders
    columns={[{ accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }]}
    records={records}
    rowExpansion={{
      content: ({ record }) => (
        <Stack className={classes.details} p="xs" gap={6}>
          <Group gap={6}>
            <div className={classes.label}>Postal address:</div>
            <div>
              {record.streetAddress}, {record.city}, {record.state}
            </div>
          </Group>
          <Group gap={6}>
            <div className={classes.label}>Mission statement:</div>
            <Box fs="italic">“{record.missionStatement}”</Box>
          </Group>
        </Stack>
      ),
    }}
  />
);

Click on a row to test the behavior:

Name
City
State
Feest, Bogan and HerzogStromanportWY
Cummerata - KuhlmanSouth GateNH
Goyette IncDorthysideID
Runte IncMcAllenMA
Goldner, Rohan and LehnerNorth LouieWY

No records

Specifying collapse properties

Internally, the expanded content is rendered inside a Mantine Collapse component. You can customize the underlying Collapse component like so:

Name
City
State
Feest, Bogan and HerzogStromanportWY
Cummerata - KuhlmanSouth GateNH
Goyette IncDorthysideID
Runte IncMcAllenMA
Goldner, Rohan and LehnerNorth LouieWY

No records

Here is the code for the above example:

return (
  <DataTable
    // ...
    rowExpansion={{
      collapseProps: {
        transitionDuration: 500,
        animateOpacity: false,
        transitionTimingFunction: 'ease-out',
      },
      // ...
    }}
  />
);

Allowing multiple rows to be expanded at once

By default, a single row can be expanded at a certain time. You can override the default behavior like so:

Name
City
State
Feest, Bogan and HerzogStromanportWY
Cummerata - KuhlmanSouth GateNH
Goyette IncDorthysideID
Runte IncMcAllenMA
Goldner, Rohan and LehnerNorth LouieWY

No records

Here is the code for the above example:

return (
  <DataTable
    withTableBorder
    withColumnBorders
    columns={[{ accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }]}
    records={records}
    rowExpansion={{
      allowMultiple: true, // 👈 allow multiple rows to be expanded at the same time
      // ...
    }}
  />
);

Specifying which rows are initially expanded

You can specify which rows are initially expanded like so:

Name
City
State
Feest, Bogan and HerzogStromanportWY
Postal address:
21716 Ratke Drive, Stromanport, WY
Mission statement:
Innovate bricks-and-clicks metrics.
Cummerata - KuhlmanSouth GateNH
Goyette IncDorthysideID
Runte IncMcAllenMA
Goldner, Rohan and LehnerNorth LouieWY
Postal address:
632 Broadway Avenue, North Louie, WY
Mission statement:
Incubate cross-platform metrics.

No records

Here is the code for the above example:

return (
  <DataTable
    withTableBorder
    withColumnBorders
    columns={[{ accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }]}
    records={records}
    rowExpansion={{
      allowMultiple: true,
      initiallyExpanded: ({ record: { state } }) => state === 'WY', // 👈 expand rows where state is WY
      // ...
    }}
  />
);

Always expand all rows

If you want all rows to be locked in their expanded state, just set the row expansion trigger property to 'always':

Name
City
State
Feest, Bogan and HerzogStromanportWY
Postal address:
21716 Ratke Drive, Stromanport, WY
Mission statement:
Innovate bricks-and-clicks metrics.
Cummerata - KuhlmanSouth GateNH
Postal address:
6389 Dicki Stream, South Gate, NH
Mission statement:
Harness real-time channels.
Goyette IncDorthysideID
Postal address:
8873 Mertz Rapid, Dorthyside, ID
Mission statement:
Productize front-end web services.
Runte IncMcAllenMA
Postal address:
2996 Ronny Mount, McAllen, MA
Mission statement:
Engage synergistic infrastructures.
Goldner, Rohan and LehnerNorth LouieWY
Postal address:
632 Broadway Avenue, North Louie, WY
Mission statement:
Incubate cross-platform metrics.

No records

Here is the code for the above example:

return (
  <DataTable
    withTableBorder
    withColumnBorders
    columns={[{ accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }]}
    records={records}
    rowExpansion={{
      trigger: 'always', // 👈 always expand all rows
      // ...
    }}
  />
);

Using collapse() function in row expansion content

Besides the current record, the content function also receives a collapse callback that could be used, for instance, in an inline editor like so:

Name
City
State
Feest, Bogan and HerzogStromanportWY
Cummerata - KuhlmanSouth GateNH
Goyette IncDorthysideID
Runte IncMcAllenMA
Goldner, Rohan and LehnerNorth LouieWY

No records

Here is the code for the above example:

type CompanyEditorProps = {
  initialData: Company;
  onDone: (data: Company) => void;
  onCancel: () => void;
};

function CompanyEditor({ initialData, onDone, onCancel }: CompanyEditorProps) {
  const [name, setName] = useState(initialData.name);
  const [city, setCity] = useState(initialData.city);
  const [state, setState] = useState(initialData.state);
  const [streetAddress, setStreetAddress] = useState(initialData.streetAddress);
  const [missionStatement, setMissionStatement] = useState(initialData.missionStatement);

  return (
    <Box className={classes.details} p="md">
      <Grid>
        <GridCol span={{ base: 12, xs: 6 }}>
          <TextInput label="Name" size="xs" value={name} onChange={(e) => setName(e.currentTarget.value)} />
        </GridCol>
        {/* other fields... */}
        <Grid.Col span={12}>
          <Group justify="center">
            <Button variant="default" size="xs" leftSection={<IconArrowBackUp size={16} />} onClick={() => onCancel()}>
              Cancel
            </Button>
            <Button
              size="xs"
              leftSection={<IconCheck size={16} />}
              onClick={() =>
                onDone({
                  ...initialData,
                  name: name.trim(),
                  city: city.trim(),
                  state: state.trim(),
                  streetAddress: streetAddress.trim(),
                  missionStatement: missionStatement.trim(),
                })
              }
            >
              Save
            </Button>
          </Group>
        </Grid.Col>
      </Grid>
    </Box>
  );
}

export function RowExpansionExampleWithInlineEditor() {
  const [companies, setCompanies] = useState(initialRecords);
  return (
    <DataTable
      withTableBorder
      withColumnBorders
      columns={[{ accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }]}
      records={companies}
      rowExpansion={{
        content: ({ record, collapse }) => (
          <CompanyEditor
            initialData={record}
            onDone={(data) => {
              const index = companies.findIndex((c) => c.id === data.id);
              setCompanies([...companies.slice(0, index), data, ...companies.slice(index + 1)]);
              collapse(); // 👈 collapse the row after editing
            }}
            onCancel={collapse} // 👈 collapse the row if editing is cancelled
          />
        ),
      }}
    />
  );
}

Lazy-loading row expansion data

As mentioned above, the content function is lazily executed when a row is expanded to prevent creating unnecessary DOM elements.
If your row expansion content needs to show data that comes from outside the table records, you could exploit this behavior to lazy-load it only when a row is expanded:

Name
City
State
Feest, Bogan and HerzogStromanportWY
Cummerata - KuhlmanSouth GateNH
Goyette IncDorthysideID
Runte IncMcAllenMA
Goldner, Rohan and LehnerNorth LouieWY

No records

Here is the code for the above example:

function CompanyDetails({ companyId }: { companyId: string }) {
  const isMounted = useIsMounted();
  const [loading, setLoading] = useState(true);
  const [numberOfDepartments, setNumberOfDepartments] = useState<number | null>(null);
  const [numberOfEmployees, setNumberOfEmployees] = useState<number | null>(null);

  useEffect(() => {
    // simulate expensive async loading operation
    (async () => {
      setLoading(true);
      const delay = { min: 800, max: 1200 };
      const [departments, employees] = await Promise.all([
        countCompanyDepartmentsAsync({ companyId, delay }),
        countCompanyEmployeesAsync({ companyId, delay }),
      ]);
      if (isMounted()) {
        setNumberOfDepartments(departments);
        setNumberOfEmployees(employees);
        setLoading(false);
      }
    })();
  }, [companyId, isMounted]);

  return (
    <Center className={classes.details} p="sm">
      <Stack gap={6}>
        <LoadingOverlay visible={loading} />
        <Group gap={6}>
          <Box className={classes.label}>Number of departments:</Box>
          <Box className={classes.number} ta="right">
            {numberOfDepartments ?? 'loading...'}
          </Box>
        </Group>
        <Group gap={6}>
          <Box className={classes.label}>Number of employees:</Box>
          <Box className={classes.number} ta="right">
            {numberOfEmployees ?? 'loading...'}
          </Box>
        </Group>
      </Stack>
    </Center>
  );
}

export function RowExpansionExampleWithLazyLoading() {
  return (
    <DataTable
      withTableBorder
      withColumnBorders
      columns={[{ accessor: 'name' }, { accessor: 'city' }, { accessor: 'state' }]}
      records={records}
      rowExpansion={{ content: ({ record }) => <CompanyDetails companyId={record.id} /> }}
    />
  );
}

Controlled mode

You can control the row expansion feature by pointing the rowExpansion/expanded property to an object containing:

  • recordIds → an array containing the currently expanded record IDs
  • onRecordIdsChange → a callback function that gets called when the currently expanded records change

When using the row expansion feature in controlled mode, if you want to prevent the default behavior of toggling the expansion state on click, set the rowExpansion/trigger property to 'never'.

#
Name
City
State
1Feest, Bogan and HerzogStromanportWY
2Cummerata - KuhlmanSouth GateNH
3Goyette IncDorthysideID
4Runte IncMcAllenMA
5Goldner, Rohan and LehnerNorth LouieWY

No records

Here is the code for the above example:

const [expandedRecordIds, setExpandedRecordIds] = useState<string[]>([]);

const expandFirstAndThirdRow = () => {
  setExpandedRecordIds([firstRowId, thirdRowId]);
};

const expandSecondAndFourthRow = () => {
  setExpandedRecordIds([secondRowId, fourthRowId]);
};

const collapseAllRows = () => {
  setExpandedRecordIds([]);
};

return (
  <>
    {/* buttons triggering the above callbacks... */}
    <DataTable
      mt="md"
      withTableBorder
      withColumnBorders
      columns={[
        { accessor: 'number', title: '#', render: (_, index) => index + 1 },
        { accessor: 'name', width: '100%' },
        { accessor: 'city', ellipsis: true },
        { accessor: 'state' },
      ]}
      records={records}
      rowExpansion={{
        // trigger: 'never', // 👈 uncomment this if you want to disable expanding/collapsing on click
        allowMultiple: true,
        expanded: {
          recordIds: expandedRecordIds,
          onRecordIdsChange: setExpandedRecordIds,
        },
        content: ({ record }) => (
          // expansion content...
        ),
      }}
    />
  </>
);

Head over to the next example to see how you can abuse the row expansion feature to display nested tables.