Master Detail - Collapsing and Expanding Rows

You can control the collapsed/expanded state of rows in the master-detail configuration.
By default, all row details are collapsed. You can very easily change this by using the defaultRowDetailState prop.
Specyfing the default row detail state
const defaultRowDetailState = {
  collapsedRows: true,
  expandedRows: [39, 54],
};

<InfiniteTable
  columns={...}
  defaultRowDetailState={defaultRowDetailState}
  rowDetailRenderer={...}

Some of the rows in the master DataGrid are expanded by default.
Also, we have a default sort defined, by the country and city columns.
View Mode
Fork
import * as React from 'react';

import {
  DataSourceData,
  InfiniteTable,
  InfiniteTablePropColumns,
  DataSource,
  InfiniteTableRowInfo,
} from '@infinite-table/infinite-react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;

  city: string;
  currency: string;
  country: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';

  salary: number;
};

type City = {
  id: number;
  name: string;
  country: string;
};

const masterColumns: InfiniteTablePropColumns<City> = {
  id: {
    field: 'id',
    header: 'ID',
    defaultWidth: 70,
    renderRowDetailIcon: true,
  },
  country: { field: 'country', header: 'Country' },
  city: { field: 'name', header: 'City', defaultFlex: 1 },
};

const detailColumns: InfiniteTablePropColumns<Developer> = {
  firstName: {
    field: 'firstName',
    header: 'First Name',
  },
  salary: {
    field: 'salary',
    type: 'number',
  },

  stack: { field: 'stack' },
  currency: { field: 'currency' },
  city: { field: 'city' },
};

const domProps = {
  style: {
    height: '100%',
  },
};
const shouldReloadData = {
  sortInfo: true,
  filterValue: true,
};

function renderDetail(rowInfo: InfiniteTableRowInfo<City>) {
  console.log('rendering detail for master row', rowInfo.id);
  return (
    <DataSource<Developer>
      data={detailDataSource}
      primaryKey="id"
      shouldReloadData={shouldReloadData}
    >
      <InfiniteTable<Developer>
        columnDefaultWidth={150}
        columnMinWidth={50}
        columns={detailColumns}
      />
    </DataSource>
  );
}

const defaultRowDetailState = {
  collapsedRows: true as const,
  expandedRows: [39, 54],
};

export default () => {
  return (
    <>
      <DataSource<City>
        data={citiesDataSource}
        primaryKey="id"
        defaultSortInfo={[
          {
            field: 'country',
            dir: 1,
          },
          {
            field: 'name',
            dir: 1,
          },
        ]}
      >
        <InfiniteTable<City>
          domProps={domProps}
          columnDefaultWidth={150}
          defaultRowDetailState={defaultRowDetailState}
          columnMinWidth={50}
          columns={masterColumns}
          rowDetailHeight={200}
          rowDetailRenderer={renderDetail}
        />
      </DataSource>
    </>
  );
};

// fetch an array of cities from the server
const citiesDataSource: DataSourceData<City> = () => {
  const cityNames = new Set<string>();
  const result: City[] = [];
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + `/developers1k-sql`)
    .then((response) => response.json())
    .then((response) => {
      response.data.forEach((data: Developer) => {
        if (cityNames.has(data.city)) {
          return;
        }
        cityNames.add(data.city);
        result.push({
          name: data.city,
          country: data.country,
          id: result.length,
        });
      });

      return result;
    });
};

const detailDataSource: DataSourceData<Developer> = ({
  filterValue,
  sortInfo,
  masterRowInfo,
}) => {
  if (sortInfo && !Array.isArray(sortInfo)) {
    sortInfo = [sortInfo];
  }

  if (!filterValue) {
    filterValue = [];
  }
  if (masterRowInfo) {
    // filter by master country and city
    filterValue = [
      {
        field: 'city',
        filter: {
          operator: 'eq',
          type: 'string',
          value: masterRowInfo.data.name,
        },
      },
      {
        field: 'country',
        filter: {
          operator: 'eq',
          type: 'string',
          value: masterRowInfo.data.country,
        },
      },
      ...filterValue,
    ];
  }
  const args = [
    sortInfo
      ? 'sortInfo=' +
        JSON.stringify(
          sortInfo.map((s) => ({
            field: s.field,
            dir: s.dir,
          })),
        )
      : null,

    filterValue
      ? 'filterBy=' +
        JSON.stringify(
          filterValue.map(({ field, filter }) => {
            return {
              field: field,
              operator: filter.operator,
              value:
                filter.type === 'number' ? Number(filter.value) : filter.value,
            }

Understanding and defining the collapse/expand state for row details#

When you want to specify a different collapse/expand state of the row details (since by default they are all collapsed, and you might want to expand some of them), you need to use the defaultRowDetailState prop, or its controlled counterpart - the rowDetailState prop.
The row detail state can be defined in two ways:
  • either specify collapsedRows: true (which means all rows are collapsed by default) and specify an array of expandedRows, which will contain the ids of the rows that should be rendered as expanded.
const defaultRowDetailState = {
  collapsedRows: true,
  expandedRows: ['id-1', 'id-2', 'id-56'],
};
  • or specify expandedRows: true (which means all rows are expanded by default) and specify an array of collapsedRows, which will contain the ids of the rows that should be rendered as collapsed.
const rowDetailState = {
  expandedRows: true,
  collapsedRows: ['id-1', 'id-2', 'id-56'],
};
You can pass these objects into either the defaultRowDetailState (uncontrolled) or the rowDetailState (controlled).
If you're using the controlled rowDetailState prop, you'll need to respond to user interaction by listening to onRowDetailStateChange and updating the value of rowDetailState accordingly.
As an alternative to using the object literals as specified above, you can import the RowDetailState class from @infinite-table/infinite-react and use it to define the state of the row details. You can pass instances of RowDetailState into the defaultRowDetailState or rowDetailState props.
Passing an instance of RowDetailState to the InfiniteTable
import { RowDetailState } from '@infinite-table/infinite-react';

const rowDetailState = new RowDetailState({
  collapsedRows: true,
  expandedRows: [2, 3, 4],
});

<InfiniteTable<DATA_TYPE> rowDetailState={rowDetailState} />;
Passing an object literal to the InfiniteTable
<InfiniteTable<DATA_TYPE>
  rowDetailState={{
    collapsedRows: true,
    expandedRows: [2, 3, 4],
  }}
/>
See our type definitions for more details on row detail state.
Some of the rows in the master DataGrid are expanded by default.
We use the controlled rowDetailState prop to manage the state of the row details and update it by using onRowDetailStateChange.
View Mode
Fork
import * as React from 'react';

import {
  DataSourceData,
  InfiniteTable,
  InfiniteTablePropColumns,
  DataSource,
  InfiniteTableRowInfo,
  RowDetailStateObject,
  InfiniteTableApi,
} from '@infinite-table/infinite-react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;

  city: string;
  currency: string;
  country: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';

  salary: number;
};

type City = {
  id: number;
  name: string;
  country: string;
};

const masterColumns: InfiniteTablePropColumns<City> = {
  id: {
    field: 'id',
    header: 'ID',
    defaultWidth: 70,
    renderRowDetailIcon: true,
  },
  country: { field: 'country', header: 'Country' },
  city: { field: 'name', header: 'City', defaultFlex: 1 },
};

const detailColumns: InfiniteTablePropColumns<Developer> = {
  firstName: {
    field: 'firstName',
    header: 'First Name',
  },
  salary: {
    field: 'salary',
    type: 'number',
  },

  stack: { field: 'stack' },
  currency: { field: 'currency' },
  city: { field: 'city' },
};

const domProps = {
  style: {
    height: '100%',
  },
};

const shouldReloadData = {
  sortInfo: true,
  filterValue: true,
};
function renderDetail(rowInfo: InfiniteTableRowInfo<City>) {
  console.log('rendering detail for master row', rowInfo.id);
  return (
    <DataSource<Developer>
      data={detailDataSource}
      primaryKey="id"
      shouldReloadData={shouldReloadData}
    >
      <InfiniteTable<Developer>
        columnDefaultWidth={150}
        columnMinWidth={50}
        columns={detailColumns}
      />
    </DataSource>
  );
}

export default () => {
  const [rowDetailState, setRowDetailState] =
    React.useState<RowDetailStateObject>({
      collapsedRows: true as const,
      expandedRows: [39, 54],
    });

  const [api, setApi] = React.useState<InfiniteTableApi<City> | null>(null);

  return (
    <>
      <div
        style={{
          display: 'flex',
          flexFlow: 'row',
          color: 'var(--infinite-cell-color)',
          background: 'var(--infinite-background)',
          maxHeight: 200,
          overflow: 'auto',
          gap: 10,
        }}
      >
        <code style={{ paddingInline: 10 }}>
          <pre>Row detail state: {JSON.stringify(rowDetailState, null, 2)}</pre>
        </code>
        <div
          style={{
            display: 'flex',
            gap: 10,
            padding: 10,
            alignItems: 'flex-start',
          }}
        >
          <button onClick={() => api?.rowDetailApi.expandAllDetails()}>
            Expand All
          </button>
          <button
            onClick={() => {
              // we could use the api to collapse all details
              // api?.rowDetailApi.collapseAllDetails();
              // but we can also use the controlled prop
              setRowDetailState({
                collapsedRows: true,
                expandedRows: [],
              });
            }}
          >
            Collapse All
          </button>
        </div>
      </div>
      <DataSource<City>
        data={citiesDataSource}
        primaryKey="id"
        defaultSortInfo={[
          {
            field: 'country',
            dir: 1,
          },
          {
            field: 'name',
            dir: 1,
          },
        ]}
      >
        <InfiniteTable<City>
          domProps={domProps}
          onReady={({ api }) => {
            setApi(api);
          }}
          columnDefaultWidth={150}
          rowDetailState={rowDetailState}
          onRowDetailStateChange={(rowDetailState) => {
            setRowDetailState(rowDetailState.getState());
          }}
          columnMinWidth={50}
          columns={masterColumns}
          rowDetailHeight={200}
          rowDetailRenderer={renderDetail}
        />
      </DataSource>
    </>
  );
};

// fetch an array of cities from the server
const citiesDataSource: DataSourceData<City> = () => {
  const cityNames = new Set<string>();
  const result: City[] = [];
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + `/developers1k-sql`)
    .then((response) => response.json())
    .then((response) => {
      response.data.forEach((data: Developer) => {
        if (cityNames.has(data.city)) {
          return;
        }
        cityNames.add(data.city);
        result.push({
          name: data.city,
          country: data.country,
          id: result.length,
        });
      });

      return result;
    });
};

const detailDataSource: DataSourceData<Developer> = ({
  filterValue,
  sortInfo,
  masterRowInfo,
}) => {
  if (sortInfo && !Array.isArray(sortInfo)) {
    sortInfo = [sortInfo];
  }

  if (!filterValue) {
    filterValue = [];
  }
  if (masterRowInfo) {
    // filter by master country and city
    filterValue = [
      {
        field: 'city',
        filter: {
          operator: 'eq',
          type: 'string',
          value: masterRowInfo.data.name,
        },
      },
      {
        field: 'country',
        filter: {
          operator: 'eq',
          type: 'string',
          value: masterRowInfo.data.country,
        },
      },
      ...filterValue,
    ];
  }
  const args = [
    sortInfo
      ? 'sortInfo=' +
        JSON.stringify(
          sortInfo.map((s) => ({
            field: s.field,
            dir: s.dir,
          })),
        )
      : null,

    filterValue
      ? 'filterBy=' +
        JSON.stringify(
          filterValue.map(({ field, filter }) => {
            return {
              field: field,
              operator: filter.operator,
              value:
                filter.type === 'number' ? Number(filter.value) : filter.value,
            }

Listening to row detail state changes#

In order to be notified when the collapse/expand state of row details changes, you can use the onRowDetailStateChange prop.
This function is called with only one argument - the new rowDetailState. Please note this is an instance of RowDetailState. If you want to use the object literal, make sure you call getState() on the instance of RowDetailState.
Using the onRowDetailStateChange listener
function App() {
  const [rowDetailState, setRowDetailState] = React.useState<RowDetailStateObject>({
    collapsedRows: true as const,
    expandedRows: [39, 54],
  });

  return <DataSource<DATA_TYPE> {...}>
    <InfiniteTable<DATA_TYPE>
      rowDetailState={rowDetailState}
      onRowDetailStateChange={(rowDetailStateInstance) => {
        setRowDetailState(rowDetailStateInstance.getState());
      }}
      columnMinWidth={50}
      columns={masterColumns}
      rowDetailHeight={200}
      rowDetailRenderer={renderDetail}
    />
  </DataSource>
}
When using the controlled rowDetailState, you'll need to respond to the user interaction by using the onRowDetailStateChange listener, in order to update the controlled rowDetailState.
This allows you to manage the state of the row details yourself - making it easy to expand/collapse all rows, or to expand/collapse a specific row by simply updating the value of the rowDetailState prop.
const [rowDetailState, setRowDetailState] =
  React.useState<RowDetailStateObject>({
    collapsedRows: true,
    expandedRows: [39, 54],
  });

const expandAll = () => {
  setRowDetailState({
    collapsedRows: [],
    expandedRows: true,
  });
};
const collapseAll = () => {
  setRowDetailState({
    collapsedRows: true,
    expandedRows: [],
  });
};

return (
  <>
    <button onClick={expandAll}>Expand All</button>
    <button onClick={collapseAll}>Collapse All</button>
    <InfiniteTable<DATA_TYPE> rowDetailState={rowDetailState} />
  </>

If you prefer the more imperative approach, you can still use the Row Detail API to expand or collapse details for rows.

Single row expand#

Using the controlled rowDetailState prop is very powerful - it allows you to configure the expand state to only allow one row to be expanded at a time, if that's something you need.
This means that if any other row(s) are expanded and you expand a new row, the previously expanded rows will all be collapsed.
In this demo we allow only one row to be expanded at any given time.
We use the controlled rowDetailState prop to manage the state of the row details and update it by using onRowDetailStateChange.
View Mode
Fork
import * as React from 'react';

import {
  DataSourceData,
  InfiniteTable,
  InfiniteTablePropColumns,
  DataSource,
  RowDetailStateObject,
  useMasterRowInfo,
} from '@infinite-table/infinite-react';

type Developer = {
  id: number;
  firstName: string;
  lastName: string;

  city: string;
  currency: string;
  country: string;
  preferredLanguage: string;
  stack: string;
  canDesign: 'yes' | 'no';

  salary: number;
};

type City = {
  id: number;
  name: string;
  country: string;
};

const masterColumns: InfiniteTablePropColumns<City> = {
  id: {
    field: 'id',
    header: 'ID',
    defaultWidth: 70,
    renderRowDetailIcon: true,
  },
  country: { field: 'country', header: 'Country' },
  city: { field: 'name', header: 'City', defaultFlex: 1 },
};

const detailColumns: InfiniteTablePropColumns<Developer> = {
  firstName: {
    field: 'firstName',
    header: 'First Name',
  },
  salary: {
    field: 'salary',
    type: 'number',
  },

  stack: { field: 'stack' },
  currency: { field: 'currency' },
  city: { field: 'city' },
};

const domProps = {
  style: {
    height: '100%',
  },
};
const shouldReloadData = {
  sortInfo: true,
  filterValue: true,
};
function RowDetail() {
  const masterRowInfo = useMasterRowInfo<City>();
  if (!masterRowInfo) {
    return null;
  }
  console.log('rendering detail for master row', masterRowInfo.id);
  return (
    <DataSource<Developer>
      data={detailDataSource}
      primaryKey="id"
      shouldReloadData={shouldReloadData}
    >
      <InfiniteTable<Developer>
        columnDefaultWidth={150}
        columnMinWidth={50}
        columns={detailColumns}
      />
    </DataSource>
  );
}

const components = {
  RowDetail,
};

export default () => {
  const [rowDetailState, setRowDetailState] = React.useState<
    RowDetailStateObject<any>
  >({
    collapsedRows: true as const,
    expandedRows: [39],
  });

  return (
    <>
      <code
        style={{
          padding: 10,
          color: 'var(--infinite-cell-color)',
          background: 'var(--infinite-background)',
          maxHeight: 200,
          overflow: 'auto',
        }}
      >
        <pre>
          Current expanded row:{' '}
          {rowDetailState.expandedRows === true
            ? '---'
            : rowDetailState.expandedRows[0] ?? 'none'}
        </pre>
      </code>
      <DataSource<City>
        data={citiesDataSource}
        primaryKey="id"
        defaultSortInfo={[
          {
            field: 'country',
            dir: 1,
          },
          {
            field: 'name',
            dir: 1,
          },
        ]}
      >
        <InfiniteTable<City>
          domProps={domProps}
          onReady={({ api }) => {
            console.log(api.rowDetailApi);
          }}
          columnDefaultWidth={150}
          rowDetailState={rowDetailState}
          onRowDetailStateChange={(newRowDetailState) => {
            const newState = newRowDetailState.getState();

            const expandedRows =
              newState.expandedRows === true ? [] : newState.expandedRows;

            const oldExpandedRows = new Set(
              rowDetailState.expandedRows === true
                ? []
                : rowDetailState.expandedRows,
            );

            for (const row of expandedRows) {
              if (!oldExpandedRows.has(row)) {
                setRowDetailState({
                  collapsedRows: true,
                  expandedRows: [row],
                });
                return;
              }
            }

            setRowDetailState(newState);
          }}
          columnMinWidth={50}
          columns={masterColumns}
          components={components}
        />
      </DataSource>
    </>
  );
};

// fetch an array of cities from the server
const citiesDataSource: DataSourceData<City> = () => {
  const cityNames = new Set<string>();
  const result: City[] = [];
  return fetch('https://infinite-table.com/.netlify/functions/json-server' + `/developers1k-sql`)
    .then((response) => response.json())
    .then((response) => {
      response.data.forEach((data: Developer) => {
        if (cityNames.has(data.city)) {
          return;
        }
        cityNames.add(data.city);
        result.push({
          name: data.city,
          country: data.country,
          id: result.length,
        });
      });

      return result;
    });
};

const detailDataSource: DataSourceData<Developer> = ({
  filterValue,
  sortInfo,
  masterRowInfo,
}) => {
  if (sortInfo && !Array.isArray(sortInfo)) {
    sortInfo = [sortInfo];
  }

  if (!filterValue) {
    filterValue = [];
  }
  if (masterRowInfo) {
    // filter by master country and city
    filterValue = [
      {
        field: 'city',
        filter: {
          operator: 'eq',
          type: 'string',
          value: masterRowInfo.data.name,
        },
      },
      {
        field: 'country',
        filter: {
          operator: 'eq',
          type: 'string',
          value: masterRowInfo.data.country,
        },
      },
      ...filterValue,
    ];
  }
  const args = [
    sortInfo
      ? 'sortInfo=' +
        JSON.stringify(
          sortInfo.map((s) => ({
            field: s.field,
            dir: s.dir,
          })),
        )
      : null,

    filterValue
      ? 'filterBy=' +
        JSON.stringify(
          filterValue.map(({ field, filter }) => {
            return {
              field: field,
              operator: filter.operator,
              value:
                filter.type === 'number' ? Number(filter.value) : filter.value,
            }