MUIのテーブルの処理が重い場合はreact-virtuosoで軽くできる

MUI (Material UI)のTableは大量のデータだと重くなる

大量のデータを表示するReactアプリケーションでは、表示パフォーマンスの最適化が欠かせません。

特にMUI (Material UI)のTableを使用して大量のデータを読み込んでいる場合は、スクロールやクリックなどの処理が重くなってしまいます。

MUI (Material UI)のTableは大量のデータだと重くなる

例えば、以下のテーブルは2000件のランダムなダミーデータを入れて、チェックボックスで選択して送信ボタンを押すとConsoleに選択したものを表示するという簡単なサンプルです。

このサンプルのチェックボックスを押すと、実際に押してから反映されるまで少し時間がかかることが確認できます。

App.tsx
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import Paper from '@mui/material/Paper';
import Checkbox from '@mui/material/Checkbox';
import Button from '@mui/material/Button';
import { useState } from 'react';
import Chance from 'chance';

interface Data {
  id: number;
  checkbox?: boolean;
  name: string;
  age: number;
  phone: string;
  state: string;
}

interface ColumnData {
  dataKey: keyof Data;
  label: string;
  numeric?: boolean;
  width?: number;
}

const chance = new Chance(1000);

function createData(id: number): Data {
  return {
    id,
    name: chance.name(),
    age: chance.age(),
    state: chance.state({ full: true }),
    phone: chance.phone(),
  };
}

const columns: ColumnData[] = [
  {
    width: 50,
    label: '',
    dataKey: 'checkbox' as keyof Data,
  },
  {
    width: 100,
    label: 'Name',
    dataKey: 'name',
  },
  {
    width: 50,
    label: 'Age',
    dataKey: 'age',
    numeric: true,
  },
  {
    width: 100,
    label: 'State',
    dataKey: 'state',
  },
  {
    width: 100,
    label: 'Phone Number',
    dataKey: 'phone',
  },
];

const rows: Data[] = Array.from({ length: 2000 }, (_, index) =>
  createData(index)
);

export default function ReactVirtualizedTable() {
  const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set());

  const handleRowSelect = (rowId: number) => {
    const newSelected = new Set(selectedRows);
    if (newSelected.has(rowId)) {
      newSelected.delete(rowId);
    } else {
      newSelected.add(rowId);
    }
    setSelectedRows(newSelected);
  };

  const handleSelectAll = () => {
    if (selectedRows.size === rows.length) {
      setSelectedRows(new Set());
    } else {
      setSelectedRows(new Set(rows.map((row) => row.id)));
    }
  };

  const handleSubmit = () => {
    const selectedData = rows.filter((row) => selectedRows.has(row.id));
    console.log('Selected rows:', selectedData);
  };
  return (
    <>
      <Paper
        sx={{
          height: 600,
          width: '100%',
          maxWidth: '800px',
          marginInline: 'auto',
        }}
      >
        <TableContainer sx={{ height: '100%', overflow: 'auto' }}>
          <Table
            sx={{
              borderCollapse: 'separate',
              tableLayout: 'fixed',
            }}
          >
            <TableHead
              sx={{
                position: 'sticky',
                top: 0,
                zIndex: 2,
              }}
            >
              <TableRow>
                {columns.map((column) => (
                  <TableCell
                    key={column.dataKey}
                    variant="head"
                    style={{ width: column.width }}
                    sx={{ backgroundColor: 'background.paper' }}
                  >
                    {column.dataKey === 'checkbox' ? (
                      <Checkbox
                        checked={selectedRows.size === rows.length}
                        indeterminate={
                          selectedRows.size > 0 &&
                          selectedRows.size < rows.length
                        }
                        onChange={handleSelectAll}
                      />
                    ) : (
                      column.label
                    )}
                  </TableCell>
                ))}
              </TableRow>
            </TableHead>
            <TableBody>
              {rows.map((row) => (
                <TableRow key={row.id}>
                  {columns.map((column) => (
                    <TableCell key={column.dataKey}>
                      {column.dataKey === 'checkbox' ? (
                        <Checkbox
                          checked={selectedRows.has(row.id)}
                          onChange={() => handleRowSelect(row.id)}
                        />
                      ) : (
                        row[column.dataKey]
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))}
            </TableBody>
          </Table>
        </TableContainer>
      </Paper>
      <div style={{ padding: '16px', textAlign: 'center' }}>
        <Button
          variant="contained"
          color="primary"
          onClick={handleSubmit}
          disabled={selectedRows.size === 0}
        >
          送信 ({selectedRows.size}件選択)
        </Button>
      </div>
    </>
  );
}

MUIテーブル(2000件)の処理が重いサンプル

このようなケースでは、react-virtuosoをインストールして仮想レンダリングでスクロール位置に応じて必要な行だけ描画すれば問題を解決できます。

react-virtuosoの使い方

まず、以下のコマンドでreact-virtuosoをインストールします。

npm i react-virtuoso

react-virtuosoをインストールしたら、TableVirtuosoをインポートして、各プロパティで必要なものを指定します。

TSX
import { TableVirtuoso } from 'react-virtuoso'

<Paper style={{ height: 600, width: '100%' }}>
  <TableVirtuoso
    data={rows}
    components={VirtuosoTableComponents}
    fixedHeaderContent={fixedHeaderContent}
    itemContent={rowContent}
  />
</Paper>

data={rows}

表示するデータ配列(=1行分のデータ)を指定します。
この記事の例だとcreateDataで生成したダミーデータ配列を使用しています。

TSX
const rows: Data[] = Array.from({ length: 2000 }, (_, index) =>
  createData(index)
)

components={VirtuosoTableComponents}

TableVirtuoso に対して、thead, tbody, tr, td などをどのように描画するかを指定します。

この記事の例だとMUIを使用しているので以下のように各要素を指定しています。

TSX
import type { TableComponents } from 'react-virtuoso'

const VirtuosoTableComponents: TableComponents<Data> = {
  Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
    <TableContainer component={Paper} {...props} ref={ref} />
  )),
  Table: (props) => (
    <Table {...props} sx={{ borderCollapse: 'separate', tableLayout: 'fixed' }} />
  ),
  TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
    <TableHead {...props} ref={ref} />
  )),
  TableRow,
  TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
    <TableBody {...props} ref={ref} />
  )),
}

fixedHeaderContent={fixedHeaderContent}

テーブルヘッダーの中身を定義する関数です。

このテーブルヘッダーは「position: 'sticky'」を指定しなくても固定になります。

TSX
const fixedHeaderContent = () => {
  return (
    <TableRow>
      {columns.map((column) => (
        <TableCell
          key={column.dataKey}
          variant="head"
          sx={{ width: column.width, backgroundColor: 'background.paper' }}
        >
          {column.dataKey === 'checkbox' ? (
            <Checkbox
              checked={selectedRows.size === rows.length}
              indeterminate={
                selectedRows.size > 0 &&
                selectedRows.size < rows.length
              }
              onChange={handleSelectAll}
            />
          ) : (
            column.label
          )}
        </TableCell>
      ))}
    </TableRow>
  )
}

itemContent={(index, item) => JSX}

テーブルの各行のセルをレンダリングする関数です。

返り値としてテーブルのセル <TableCell> を複数返します。

TSX
const rowContent = (_index: number, row: Data) => {
  return columns.map((column) => (
    <TableCell key={column.dataKey}>
      {column.dataKey === 'checkbox' ? (
        <Checkbox
          checked={selectedRows.has(row.id)}
          onChange={() => handleRowSelect(row.id)}
        />
      ) : (
        row[column.dataKey]
      )}
    </TableCell>
  ))
}

以上の点を踏まえた上で作成したものが、以下のコードになります。

App.tsx
import Table from '@mui/material/Table'
import TableBody from '@mui/material/TableBody'
import TableCell from '@mui/material/TableCell'
import TableContainer from '@mui/material/TableContainer'
import TableHead from '@mui/material/TableHead'
import TableRow from '@mui/material/TableRow'
import Paper from '@mui/material/Paper'
import Checkbox from '@mui/material/Checkbox'
import Button from '@mui/material/Button'
import { TableVirtuoso } from 'react-virtuoso'
import type { TableComponents } from 'react-virtuoso'
import React, { useState } from 'react'
import Chance from 'chance'

interface Data {
  id: number
  checkbox?: boolean
  name: string
  age: number
  phone: string
  state: string
}

interface ColumnData {
  dataKey: keyof Data
  label: string
  numeric?: boolean
  width?: number
}

const chance = new Chance(1000)

function createData(id: number): Data {
  return {
    id,
    name: chance.name(),
    age: chance.age(),
    state: chance.state({ full: true }),
    phone: chance.phone(),
  }
}

const columns: ColumnData[] = [
  {
    width: 50,
    label: '',
    dataKey: 'checkbox' as keyof Data,
  },
  {
    width: 100,
    label: 'Name',
    dataKey: 'name',
  },
  {
    width: 50,
    label: 'Age',
    dataKey: 'age',
    numeric: true,
  },
  {
    width: 100,
    label: 'State',
    dataKey: 'state',
  },
  {
    width: 100,
    label: 'Phone Number',
    dataKey: 'phone',
  },
]

const rows: Data[] = Array.from({ length: 2000 }, (_, index) =>
  createData(index)
)

const VirtuosoTableComponents: TableComponents<Data> = {
  Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
    <TableContainer component={Paper} {...props} ref={ref} />
  )),
  Table: (props) => (
    <Table {...props} sx={{ borderCollapse: 'separate', tableLayout: 'fixed' }} />
  ),
  TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
    <TableHead {...props} ref={ref} />
  )),
  TableRow,
  TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
    <TableBody {...props} ref={ref} />
  )),
}

export default function ReactVirtualizedTable() {
  const [selectedRows, setSelectedRows] = useState<Set<number>>(new Set())

  const handleRowSelect = (rowId: number) => {
    const newSelected = new Set(selectedRows)
    if (newSelected.has(rowId)) {
      newSelected.delete(rowId)
    } else {
      newSelected.add(rowId)
    }
    setSelectedRows(newSelected)
  }

  const handleSelectAll = () => {
    if (selectedRows.size === rows.length) {
      setSelectedRows(new Set())
    } else {
      setSelectedRows(new Set(rows.map((row) => row.id)))
    }
  }

  const handleSubmit = () => {
    const selectedData = rows.filter((row) => selectedRows.has(row.id))
    console.log('Selected rows:', selectedData)
  }

  const fixedHeaderContent = () => {
    return (
      <TableRow>
        {columns.map((column) => (
          <TableCell
            key={column.dataKey}
            variant="head"
            sx={{ width: column.width, backgroundColor: 'background.paper' }}
          >
            {column.dataKey === 'checkbox' ? (
              <Checkbox
                checked={selectedRows.size === rows.length}
                indeterminate={
                  selectedRows.size > 0 &&
                  selectedRows.size < rows.length
                }
                onChange={handleSelectAll}
              />
            ) : (
              column.label
            )}
          </TableCell>
        ))}
      </TableRow>
    )
  }

  const rowContent = (_index: number, row: Data) => {
    return columns.map((column) => (
      <TableCell key={column.dataKey}>
        {column.dataKey === 'checkbox' ? (
          <Checkbox
            checked={selectedRows.has(row.id)}
            onChange={() => handleRowSelect(row.id)}
          />
        ) : (
          row[column.dataKey]
        )}
      </TableCell>
    ))
  }
  
  return (
    <>
      <Paper style={{ height: 600, width: '100%' }}>
        <TableVirtuoso
          data={rows}
          components={VirtuosoTableComponents}
          fixedHeaderContent={fixedHeaderContent}
          itemContent={rowContent}
        />
      </Paper>
      <div style={{ padding: '16px', textAlign: 'center' }}>
        <Button
          variant="contained"
          color="primary"
          onClick={handleSubmit}
          disabled={selectedRows.size === 0}
        >
          送信 ({selectedRows.size}件選択)
        </Button>
      </div>
    </>
  )
}

作成したサンプルを見るとチェックボックスをクリックしても、すぐに選択が反映されていることが確認できます。

データは2000件にしていますが、react-virtuosoは仮想化技術を使って表示されている範囲だけDOMに描画しているため、この描画方法であればデータが数万件あったとしても高速でスクロールやクリックなどができます。

MUIのテーブルにreact-virtuosoを使用したサンプル