React + Typescriptで型安全にColumnとRowを指定できるテーブルComponentを作成してみた

# 前書き

* Material-uiを使ったテーブルを作成したい
* 共通仕様にできるコンポーネントとして独自設計したい
* ColumnとRowをObject型、かつPropsとして指定する設計にする
* ColumnとRowの対応関係をなるべく型安全に定義したい

これらを満たすテーブルコンポーネントを`React + TypeScript`で 作成してみました。

# 技術

* React 17.0.2
* TypeScript 4.4.2
* Material-ui/core 4.12.3

コードでは以下のExampleをラップしたコンポーネントを作成
https://material-ui.com/components/tables/#fixed-header

# コード


## Component

```tsx
type TColumns<T extends string> = {
  id: T;
  label: string;
  width?: number;
};

type TRows<T extends string> = {
  [key in T]: string | number | React.FC;
};

type Props<T extends string> = {
  columns: TColumns<T>[];
  rows: TRows<T>[];
};

/** ※公式引用 */
const useStyles = makeStyles({
  root: {
    width: "100%",
  },
  container: {
    maxHeight: 440,
  },
});

const GenericTable = <T extends string>(props: Props<T>): JSX.Element => {
  const classes = useStyles();

  const [page, setPage] = React.useState<number>(0);
  const [rowsPerPage, setRowsPerPage] = React.useState<number>(10);

  return (
    <>
      <TableContainer className={classes.container}>
        <Table stickyHeader aria-label="sticky table">
          <TableHead>
            <TableRow>
              {props.columns.map((column) => (
                <TableCell key={column.id} align="left" style={{ minWidth: column.width }}>
                  {column.label}
                </TableCell>
              ))}
            </TableRow>
          </TableHead>
          <TableBody>
            {props.rows.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row, i) => {
              return (
                <TableRow hover role="checkbox" tabIndex={-1} key={i}>
                  {props.columns.map((column) => {
                    return (
                      <TableCell key={column.id} align={column.align}>
                        {row[column.id]}
                      </TableCell>
                    );
                  })}
                </TableRow>
              );
            })}
          </TableBody>
        </Table>
      </TableContainer>
      <TablePagination
        rowsPerPageOptions={[10, 25, 100]}
        component="div"
        count={10}
        rowsPerPage={rowsPerPage}
        page={page}
        onPageChange={(event: any, newPage: number) => {
          setPage(newPage);
        }}
        onRowsPerPageChange={(event: React.ChangeEvent<HTMLInputElement>) => {
          setRowsPerPage(+event.target.value);
          setPage(0);
        }}
        labelDisplayedRows={({ from, to, count }) => {
          return `${from}から${to} 件 / ${count}件中`;
        }}
        labelRowsPerPage={<>表示件数</>}
      />
    </>
  );
};

export default GenericTable;

```

# 使用例

```tsx
import GenericTable, { TColumns, TRows } from "...GenericTable";

// カラム値を定義
type ColumnType = "id" | "name";

const columns: TColumns<ColumnType>[] = [
  { id: "id", label: "ユーザーID", width: 240 },
  { id: "name", label: "ユーザー名", width: 240 },
];

const rows: TRows<ColumnType>[] = [
  { id: 1, name: "トム" },
  { id: 2, name: "ブラウン" },
];

// コンポーネント呼び出し
<GenericTable<ColumnType> columns={columns} rows={rows} />

```

# 良い点

* 何を列としているのか分かり易い
* 依存性を1つ（=`ColumnType`）に集約できる
* 以下のような事象をエラー検知できる

```tsx
type ColumnType = "id" | "name";

// カラムに定義外のidが入る
const columns: TColumns<ColumnType>[] = [
  { id: "id", label: "ユーザーID", width: 240 },
  { id: "name", label: "ユーザー名", width: 240 },
  { id: "neta", label: "ネタ", width: 240 }, // ダメ〜
];

//  データに定義外のカラム値を混ぜる
const rows: TRows<ColumnType>[] = [
  { id: 1, name: "トム", neta: "エラーを" },
  { id: 2, name: "ブラウン", neta: "放置します" }, // ダメ〜
];
```

# 微妙な点

* ColumnTypeなる型を別途定義する必要がある
* 重複するid値を持つなどのケースはエラー検知できない

```tsx
type ColumnType = "id" | "name";

// 例
const columns: TColumns<ColumnType>[] = [
  { id: "id", label: "ユーザーID", width: 240 },
  { id: "name", label: "ユーザー名", width: 240 },
  { id: "name", label: "ユーザー名２", width: 240 }, // エラー出ない
];

```

# 後書き

Tableコンポーネントを作る機会があったので、
TypeScriptのGenericsを使って設計を考えてみました。

ご意見ある方いましたら、よければご教授いただけたら嬉しいです。