material-tableでページネーションをサーバーサイドで行いたいwithReduxのひと

こんにちは。あまりにも長いことハマってしまったので備忘録として書いておきます。

material-tableというOSSをとあるプロジェクトで使っていました。
https://material-table.com

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/d3d4364e-f2eb-b11a-b766-3a0bd4f4d044.png)

ちょっとオプションを書けばこんな感じで簡単なCRUDが実装できるナウでヤングなOSSです。[material-uiの公式でもtableで色々やりたいならこれを使え](https://material-ui.com/components/tables/#material-table)と言われており、サイコーという感じで実装を進めていました。

この実装を進めている過程でハマったポイントを紹介していきたいと思います。

1. material-tableは渡ってきたデータを参照を切らない状態でそのまま編集している
2. dataについてpromiseを返さないといけない
3. createRefでrefをつくってonQueryChangeを叩かないと正しくデータが更新されない

## material-tableは渡ってきたデータの参照を切らないまま新しいキーを追加したりする

さて、このmaterial-table、ページネーション機能はついてはおりますがクライアントサイドに渡ったデータでしかページネーションしません。
まあそれは当たり前なので、ページネーションのコンポーネントを何とかしてこちらが実装したサーバサイドにリクエストを飛ばす関数に置き換えたいなと思うわけです。

ドキュメントを見ていたら `Component Override` というまさにというやつがあったので、それを見つつページネーションのコンポーネントを置き換えてみます。

```jsx
<MaterialTable
  {...other props}
  data={inputData} // storeから受け取るarrayデータ
  components={{
    Pagination: props => (
      <TablePagination
        {...props}
        rowsPerPageOptions={[5, 10]}
        count={totalRowCount}
        page={page - 1}
        rowsPerPage={pageNum}
        onChangePage={(e, _page) => getTableData({ page: _page + 1, pageNum })}
        onChangeRowsPerPage={event => getTableData({ page, pageNum: event.target.value })}
      />
    )
  }}
/>
```

これで完璧や！ページネーションボタンポチー

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/571f61ba-67d1-8d17-e7d0-fdacfa8368b4.png)

... 最初のページは表示できていたのに、急に真っ白になってしまいました。なんでや。ちなみにこれは1ページに表示する件数を変えても同じことが起こりました。

エラーメッセージでissueを放浪しているとどうやら結構な人が困っているぽく、しかもみんなreduxを使っていました。妙だな...

https://github.com/mbrn/material-table/issues/1074

しかしまあ、 `Failed prop type: Invalid prop `children` supplied to `ForwardRef(TableCell)`, expected a ReactNode.` では要領を得ないわけです。何もわからない...

ウーンと唸りながらRedux Debuggerを見てみます。すると妙な挙動に気づきました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/26ba20ac-bcad-2e54-3d60-710e05a90e98.png)

これは1ページに表示する件数を5件から10件に変更した際のdiffを見ているところなのですが、追加した覚えのない `tableData` などというキーが生えていました。
元々のデータ形式は `{id: 5, name: 'ddd', ...}` みたいな感じでネストした構造のデータは持っていなかったため、なんだこれはと言う感じです。

デバッガを行き来しつつ、closeしていないissueにも広げて確認していたら以下のようなissueがありました。

https://github.com/mbrn/material-table/issues/666

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/48d8345a-6310-4f09-3bd1-f11d071edc8b.png)

> When rendering rows in the grid, the property tableData is added to every row. This is a big problem with redux as its state MUST never be changed. Therefore, issue mentioning a readonly property may come from the fact that immutability frameworks like immer lock objects from the state for them not to be amended afterwards (as redux demands reducers and components to be pure).

`tableData` は `material-table` が勝手に追加しているだと...
`This is a big problem with redux as its state MUST never be changed.` いやほんとだよ

ここを見つつ、エラーメッセージの意味の有りそうなところを掘っていくと、次のような記述を見つけることが出来ました。

> Uncaught Error: Objects are not valid as a React child (found: object with keys {id}). If you meant to render a collection of children, use an array instead.

ネストしたオブジェクトを入れるんじゃねえ、と怒られていますね。自分ではarrayをdataに入れているつもりだったのでこのエラーを最初はすっ飛ばしていたのですが、`material-table`さんが勝手にstoreのデータを変更しているとあれば話は別です。
つまり、

1. storeに入れたarrayデータをmaterial-tableに入れる
2. renderする際にmaterial-tableが参照を切らないまま `tableData: {id:0}` みたいなキーを生やす
3. storeのデータにも同じキーが生える
4. ページネーションしてstoreのデータを更新し直そうとする際に、material-tableが更新される前に `tableData` キーが生えたstoreのデータを読む(ここのタイミングはよくわかってない)
5. material-tableは入力される時点ではarrayデータを期待しているので、エラーを吐く ( `Uncaught Error: Objects are not valid as a React child (found: object with keys {id}). If you meant to render a collection of children, use an array instead.` )

というわけで、こっちからstoreのデータとmaterial-tableに渡すデータとの参照を切ることにします。

```jsx
<MaterialTable
  {...other props}
  data={JSON.parse(JSON.stringify(inputData))} // storeから受け取ったinputData(array)を生成し直す
  components={{
    Pagination: props => (
      <TablePagination
        {...props}
        rowsPerPageOptions={[5, 10]}
        count={totalRowCount}
        page={page - 1}
        rowsPerPage={pageNum}
        onChangePage={(e, _page) => getTableData({ page: _page + 1, pageNum })}
        onChangeRowsPerPage={event => getTableData({ page, pageNum: event.target.value })}
      />
    )
  }}
/>
```

これでやっとページネーションボタンを押した瞬間に真っ白になることはなくなりました。この時点で1日ほど時間を溶かしています。

## material-tableにdataがサーバサイドから来ていることを教える必要がある

よしこれで解決や！とウキウキしながらページサイズを変更してみたところ、真っ白にはなりませんが以下のような事象が起きました。第二ラウンドです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/f3146f67-3acc-74c6-a24c-bca42a6878f5.png)

10件表示したいのに、5件しかデータが出てこないわけですね...全部で7件のデータがあるのに...

んーなんでだ、と思いながらまたドキュメントの海を放浪します。するとまた見逃していた記述が出てきました。

https://material-table.com/#/docs/features/remote-data

```jsx
import MaterialTable from 'material-table';

<MaterialTable
    // other props
    data={query =>
        new Promise((resolve, reject) => {
            // prepare your data and then call resolve like this:
            resolve({
                data: // your data array
                page: // current page number
                totalCount: // total row number
            });
        })
    }
/>;
```

なるほど、dataに必要なデータとともにpromiseを返してやればいいのか。これでmaterial-table側でdataを使ったstateの更新とかをやってくれるのかな？やったるぜ。optionsにpageSizeも渡しておこう。

```jsx
<MaterialTable
  {...other props}
  data={query =>
    new Promise((resolve, reject) => {
      resolve({
        data: JSON.parse(JSON.stringify(inputData)),// 参照を切るためにstoreから受け取ったinputData(array)を生成し直す
        page: page - 1,
        totalCount: totalRowCount
      })
    })
  }
  options={{ pageSize: pageNum }}
  components={{
    Pagination: props => (
      <TablePagination
        {...props}
        rowsPerPageOptions={[5, 10]}
        count={totalRowCount}
        page={page - 1}
        rowsPerPage={pageNum}
        onChangePage={(e, _page) => getTableData({ page: _page + 1, pageNum })}
        onChangeRowsPerPage={event => getTableData({ page, pageNum: event.target.value })}
      />
    )
  }}
/>
```

さてこれでどうだ！データを5件から10件に変更します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/aa54a458-1c54-11c8-6671-7587fa093dd2.png)

ん？なんか縦に長くなった？ていうか件数は5件のままでは？
なんで下に伸びてるんだろう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/9ae53ddd-401c-14dd-a099-3fb5a14c09b7.png)

...空のtrがめっちゃ生えている。その数を合計すると10件になります。え、どういう操作してるの？
とりあえず10件になったぞということは認識されたようですが、store側で更新されたデータが認識されていないようです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/c792b0d0-5ff7-3387-e705-295f109f183a.png)

これは上の状態時のmaterial-tableのstateを覗いている様子です。5件しかないことになっている...

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/f95c0d5b-8ca0-b1cb-1dfa-892e80ddf774.png)

redux debuggerでstoreを確認すると正しく10件で取得したデータが来ていて5件以上になっているので、material-table側でpropsとして渡ってはいるものの、stateの更新が行われていないことになります。ええ...

この時点で追加でもう一日溶かしています。泣いた。

## createRefでrefをつくってonQueryChangeを叩かないと正しくデータが更新されない

泣きながらまたissueを探します。しかし今回はエラーが出ていないので探すべきエラーメッセージもわからず...

それっぽいタイトルのissueを見ていく。`Redux with Remote Data`とな...

https://github.com/mbrn/material-table/issues/630

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/0f873be4-54bc-f3e9-6dac-31f87788319f.png)

!!!

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/eadc8d31-db30-12f9-21fd-7f530294b5d2.png)

> If you use remote data feature and change data manually, you should call onQueryChange function of table manually after data changes. Please check documentation examples of remote data feature. It has an example of tableRef usage.

イ！？ `onQueryChange` を呼んでくれだと？ remote dataのexampleに書いてあるわいと言われ、ほんまか？と思いながら見に行きます。

https://material-table.com/#/docs/features/remote-data
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/109201/682a3159-7009-fca9-391a-e8e48e85c594.png)

いや、文章としてそんな記述はどこにもないが...

と思いましたが、 `Refresh Data Example` のところに例がありました。

```jsx
class RefreshData extends React.Component {
  constructor(props) {
    super(props);

    this.tableRef = React.createRef();
  }
  
  render() {
    return (
      <MaterialTable        
        title="Refresh Data Preview"
        tableRef={this.tableRef}
        columns={[
          {
            title: 'Avatar',
            field: 'avatar',
            render: rowData => (
              <img
                style={{ height: 36, borderRadius: '50%' }}
                src={rowData.avatar}
              />
            ),
          },
          { title: 'Id', field: 'id' },
          { title: 'First Name', field: 'first_name' },
          { title: 'Last Name', field: 'last_name' },
        ]}
        data={query =>
          new Promise((resolve, reject) => {
            let url = 'https://reqres.in/api/users?'
            url += 'per_page=' + query.pageSize
            url += '&page=' + (query.page + 1)
            fetch(url)
              .then(response => response.json())
              .then(result => {
                resolve({
                  data: result.data,
                  page: result.page - 1,
                  totalCount: result.total,
                })
              })
          })
        }
        actions={[
          {
            icon: 'refresh',
            tooltip: 'Refresh Data',
            isFreeAction: true,
            onClick: () => this.tableRef.current && this.tableRef.current.onQueryChange(),
          }
        ]}
      />
    )
  }
}
```

`tableRef` ってこの一番下のactionをオーバーライドして追加しているところのことだったようです。更新ボタンを押したらref経由で `onQueryChange` をたたけと...

僕が今まで書いていたのはナウでヤングなstateless functional componentだったので、classでしか生やせないRefがある時点で面倒だ...と思いました
が、更にナウでヤングなHooksに `createRef` があったことを思い出し、何とか書くことが出来ました。サンキューHooks。

```jsx
const MTable: React.FunctionComponent<MTableProps> = ({
  [...input props]
}) => {
  const tableRef = React.useRef() // refを作る
  React.useEffect(() => {
    const tableRefCurrent: any = tableRef.current
    if (tableRef && tableRefCurrent) {
      tableRefCurrent.onQueryChange()
    }
  }, [inputData]) // データが更新されたことを検知する副作用を作って、この中でonQueryChangeを叩く
  return (
    <>
      <MaterialTable
        {...other props}
        tableRef={tableRef} // ここでrefを渡す
        data={query =>
          new Promise((resolve, reject) => {
            resolve({
              data: JSON.parse(JSON.stringify(inputData)), //参照を切るために生成し直す
              page: page - 1,
              totalCount: totalRowCount
            })
          })
        }
        options={{ pageSize: pageNum }}
        components={{
          Pagination: props => (
            <TablePagination
              {...props}
              rowsPerPageOptions={[5, 10]}
              count={totalRowCount}
              page={page - 1}
              rowsPerPage={pageNum}
              onChangePage={(e, _page) => getTableData({ page: _page + 1, pageNum })}
              onChangeRowsPerPage={event => getTableData({ page, pageNum: event.target.value })}
            />
          )
        }}
      />
    </>
  )
}
```

やったか！？

...なんとかなりました。ここまで、累計3日溶かしました。 :sob:

## まとめ

再掲です。

1. material-tableは渡ってきたデータを参照を切らない状態でそのまま編集している
2. dataについてpromiseを返さないといけない
3. createRefでrefをつくってonQueryChangeを叩かないと正しくデータが更新されない

はい、ということで解決しました。解決した感想ですが、Reduxぽくない、stateless functional componentの流れを前提としていないのがmaterial-tableということで、想定よりもすごく時間を溶かしてしまいました。
同じことを思っていた人はissueにも散見されて、

> I understand the solution, but it is far to be simple. I believe that given the fact that redux is one of the most recommended architecture for react, the paging feature should be more "redux friendly". Actually the problem is exactly the same regarding to the editable feature that is 100% not "redux friendly". I can take care of this improvement if you want.

という趣旨のコメントが何件かありました。まったくもってそのとおりでございます。。。

フロントを書くたびに新しい発見があって嬉しい限りです(?)。今後もがんばります。