こんにちは。あまりにも長いことハマってしまったので備忘録として書いておきます。
material-tableというOSSをとあるプロジェクトで使っていました。
https://material-table.com
ちょっとオプションを書けばこんな感じで簡単なCRUDが実装できるナウでヤングなOSSです。material-uiの公式でもtableで色々やりたいならこれを使えと言われており、サイコーという感じで実装を進めていました。
この実装を進めている過程でハマったポイントを紹介していきたいと思います。
- material-tableは渡ってきたデータを参照を切らない状態でそのまま編集している
- dataについてpromiseを返さないといけない
- createRefでrefをつくってonQueryChangeを叩かないと正しくデータが更新されない
material-tableは渡ってきたデータの参照を切らないまま新しいキーを追加したりする
さて、このmaterial-table、ページネーション機能はついてはおりますがクライアントサイドに渡ったデータでしかページネーションしません。
まあそれは当たり前なので、ページネーションのコンポーネントを何とかしてこちらが実装したサーバサイドにリクエストを飛ばす関数に置き換えたいなと思うわけです。
ドキュメントを見ていたら Component Override
というまさにというやつがあったので、それを見つつページネーションのコンポーネントを置き換えてみます。
<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 })}
/>
)
}}
/>
これで完璧や!ページネーションボタンポチー
... 最初のページは表示できていたのに、急に真っ白になってしまいました。なんでや。ちなみにこれは1ページに表示する件数を変えても同じことが起こりました。
エラーメッセージでissueを放浪しているとどうやら結構な人が困っているぽく、しかもみんなreduxを使っていました。妙だな...
しかしまあ、 Failed prop type: Invalid prop
childrensupplied to
ForwardRef(TableCell), expected a ReactNode.
では要領を得ないわけです。何もわからない...
ウーンと唸りながらRedux Debuggerを見てみます。すると妙な挙動に気づきました。
これは1ページに表示する件数を5件から10件に変更した際のdiffを見ているところなのですが、追加した覚えのない tableData
などというキーが生えていました。
元々のデータ形式は {id: 5, name: 'ddd', ...}
みたいな感じでネストした構造のデータは持っていなかったため、なんだこれはと言う感じです。
デバッガを行き来しつつ、closeしていないissueにも広げて確認していたら以下のようなissueがありました。
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のデータを変更しているとあれば話は別です。
つまり、
- storeに入れたarrayデータをmaterial-tableに入れる
- renderする際にmaterial-tableが参照を切らないまま
tableData: {id:0}
みたいなキーを生やす - storeのデータにも同じキーが生える
- ページネーションしてstoreのデータを更新し直そうとする際に、material-tableが更新される前に
tableData
キーが生えたstoreのデータを読む(ここのタイミングはよくわかってない) - 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に渡すデータとの参照を切ることにします。
<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がサーバサイドから来ていることを教える必要がある
よしこれで解決や!とウキウキしながらページサイズを変更してみたところ、真っ白にはなりませんが以下のような事象が起きました。第二ラウンドです。
10件表示したいのに、5件しかデータが出てこないわけですね...全部で7件のデータがあるのに...
んーなんでだ、と思いながらまたドキュメントの海を放浪します。するとまた見逃していた記述が出てきました。
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も渡しておこう。
<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件に変更します。
ん?なんか縦に長くなった?ていうか件数は5件のままでは?
なんで下に伸びてるんだろう。
...空のtrがめっちゃ生えている。その数を合計すると10件になります。え、どういう操作してるの?
とりあえず10件になったぞということは認識されたようですが、store側で更新されたデータが認識されていないようです。
これは上の状態時のmaterial-tableのstateを覗いている様子です。5件しかないことになっている...
redux debuggerでstoreを確認すると正しく10件で取得したデータが来ていて5件以上になっているので、material-table側でpropsとして渡ってはいるものの、stateの更新が行われていないことになります。ええ...
この時点で追加でもう一日溶かしています。泣いた。
createRefでrefをつくってonQueryChangeを叩かないと正しくデータが更新されない
泣きながらまたissueを探します。しかし今回はエラーが出ていないので探すべきエラーメッセージもわからず...
それっぽいタイトルのissueを見ていく。Redux with Remote Data
とな...
!!!
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
いや、文章としてそんな記述はどこにもないが...
と思いましたが、 Refresh Data Example
のところに例がありました。
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。
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日溶かしました。
まとめ
再掲です。
- material-tableは渡ってきたデータを参照を切らない状態でそのまま編集している
- dataについてpromiseを返さないといけない
- 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.
という趣旨のコメントが何件かありました。まったくもってそのとおりでございます。。。
フロントを書くたびに新しい発見があって嬉しい限りです(?)。今後もがんばります。