😽
Reactで作った3軸の表に「すべて展開/折りたたむ」ボタンを作る
以下のような3軸の表の各行を、まとめて展開/折りたたむボタンの実装方法について、備忘録がてら残しておきます。
これを閉じると以下のようになります。
データは、「学生の毎週の小テストの勉強時間、テスト結果、再試回数をまとめた表」としています。
環境
- react: 18.2.0
- react-dom: 18.2.0
- react-scripts: 5.0.1
※Reactアプリのひな型はcreate-react-app
コマンドで作成しています。
修正前のファイル
ファイル構成
src
├─ Components
│ └─ Table.js
├─ data // 画面に表示しているデータ
│ ├─ users.json
│ └─ weekly_data.json
├─ App.css
├─ App.js
└─ index.js
src/App.js のコード
import './App.css';
import Table from './Components/Table';
const App = () => {
return (
<Table />
);
}
export default App;
src/Components/Table.js のコード
RowsEachUser
に行の開閉を管理するstateを用意し、そのbool値によって詳細行の表示非表示を切り替えています。
import { useState } from 'react';
import Users from '../data/users.json';
import WeeklyData from '../data/weekly_data.json';
const displayDateList = [
"2023-04-03",
"2023-04-10",
"2023-04-17",
"2023-04-24",
];
const Table = () => {
return (
<table>
<thead>
<tr>
<th />
<th>4月3日週</th>
<th>4月10日週</th>
<th>4月17日週</th>
<th>4月24日週</th>
</tr>
</thead>
<tbody>
{Users.map(user => <RowsEachUser user={user} />)}
</tbody>
</table>
);
}
const RowsEachUser = ({ user }) => {
// 行の開閉を管理する
const [openDetail, setOpenDetail] = useState(true);
return (
<>
<tr>
<th colSpan={5} onClick={() => {setOpenDetail(!openDetail)}} className='headerCell'>
{openDetail ? '▾' : '▸'}
{user.user_name}
</th>
</tr>
{openDetail
? <>
<DataRow header="勉強時間(単位:分)" user_id={user.id} propaty="study_time" />
<DataRow header="点数(100点満点)" user_id={user.id} propaty="result" />
<DataRow header="再試回数(単位:回)" user_id={user.id} propaty="retest_times" />
</>
: null}
</>
);
}
const DataRow = ({header, user_id, propaty}) => {
return (
<tr className='dataRow'>
<th>{header}</th>
{displayDateList.map(date => {
const data = WeeklyData.find(x => x.user_id === user_id && x.week === date);
return <td className='number'>{data[propaty]}</td>
})}
</tr>
);
}
export default Table;
src/App.css のコード
table, th, td {
border: 1px solid black;
border-collapse: collapse;
}
table {
width: 500px;
margin-top: 20px;
}
th, td {
padding: 5px 7px;
}
th {
background-color: lightgray;
}
tbody th {
text-align: left;
font-weight: normal;
}
tbody tr.dataRow th{
padding-left: 18px;
}
td.number {
text-align: right;
}
data/users.json のコード
- id: 主キー。意味なし。
- user_name: 生徒名
[
{
"id": 1,
"user_name": "田中 学"
},
{
"id": 2,
"user_name": "細川 次郎"
},
{
"id": 3,
"user_name": "細谷 あゆみ"
},
{
"id": 4,
"user_name": "鈴木 道子"
}
]
src/data/weekly_data.json のコード
- id: 主キー。意味なし。
- user_id: 外部キー。
- week: 該当週の月曜日の日付。
- study_time: 勉強時間。単位は分。
- result: 小テストの結果。100点満点。
- retest_times: 再試を受けた回数。※一定の点数に達するまで再試を受け続ける。
[
{
"id": 1,
"user_id": 1,
"week": "2023-04-03",
"study_time": 60,
"result": 80,
"retest_times": 0
},
{
"id": 2,
"user_id": 2,
"week": "2023-04-03",
"study_time": 120,
"result": 90,
"retest_times": 0
},
{
"id": 3,
"user_id": 3,
"week": "2023-04-03",
"study_time": 30,
"result": 50,
"retest_times": 1
},
{
"id": 4,
"user_id": 4,
"week": "2023-04-03",
"study_time": 5,
"result": 15,
"retest_times": 3
},
{
"id": 5,
"user_id": 1,
"week": "2023-04-10",
"study_time": 66,
"result": 2,
"retest_times": 5
},
{
"id": 6,
"user_id": 2,
"week": "2023-04-10",
"study_time": 149,
"result": 55,
"retest_times": 0
},
{
"id": 7,
"user_id": 3,
"week": "2023-04-10",
"study_time": 88,
"result": 17,
"retest_times": 5
},
{
"id": 8,
"user_id": 4,
"week": "2023-04-10",
"study_time": 167,
"result": 12,
"retest_times": 4
},
{
"id": 9,
"user_id": 1,
"week": "2023-04-17",
"study_time": 124,
"result": 55,
"retest_times": 2
},
{
"id": 10,
"user_id": 2,
"week": "2023-04-17",
"study_time": 8,
"result": 36,
"retest_times": 2
},
{
"id": 11,
"user_id": 3,
"week": "2023-04-17",
"study_time": 79,
"result": 43,
"retest_times": 5
},
{
"id": 12,
"user_id": 4,
"week": "2023-04-17",
"study_time": 69,
"result": 0,
"retest_times": 5
},
{
"id": 13,
"user_id": 1,
"week": "2023-04-24",
"study_time": 29,
"result": 6,
"retest_times": 2
},
{
"id": 14,
"user_id": 2,
"week": "2023-04-24",
"study_time": 102,
"result": 83,
"retest_times": 4
},
{
"id": 15,
"user_id": 3,
"week": "2023-04-24",
"study_time": 8,
"result": 43,
"retest_times": 1
},
{
"id": 16,
"user_id": 4,
"week": "2023-04-24",
"study_time": 67,
"result": 66,
"retest_times": 1
}
]
実装方法
親コンポーネント(App.js)に、ボタンが何度押されたかをカウントするstateを作成。
子コンポーネント(Table.js)にそのカウント数を渡し、子コンポーネント内でuseEffect
を使ってカウント数の変化を感知する。
どちらのカウントが変化したかによって、開閉を管理するstateにtrue
/false
のどちらを指定するかを変える。
修正後の差分
src/App.js
+ import { useState } from 'react';
import './App.css';
import Table from './Components/Table';
const App = () => {
+ const [openCount, setOpenCount] = useState(0);
+ const [closeCount, setCloseCount] = useState(0);
return (
- <Table />
+ <>
+ <button onClick={() => {setOpenCount(openCount + 1)}}>
+ すべて展開
+ </button>
+ <button onClick={() => {setCloseCount(closeCount + 1)}}>
+ すべて折りたたむ
+ </button>
+ <Table openCount={openCount} closeCount={closeCount} />
+ </>
);
}
export default App;
src/Components/Table.js
- import { useState } from 'react';
+ import { useEffect, useState } from 'react';
import Users from '../data/users.json';
import WeeklyData from '../data/weekly_data.json';
(中略)
- const Table = () => {
+ const Table = ({openCount, closeCount}) => {
return (
(中略)
<tbody>
- {Users.map(user => <RowsEachUser user={user} />)}
+ {Users.map(user => <RowsEachUser user={user} openCount={openCount} closeCount={closeCount} />)}
</tbody>
</table>
);
}
- const RowsEachUser = ({ user }) => {
+ const RowsEachUser = ({ user, openCount, closeCount }) => {
const [openDetail, setOpenDetail] = useState(true);
+ useEffect(() => {
+ // マウント時の実行を防止
+ if (openCount !== 0)
+ setOpenDetail(true);
+ }, [openCount])
+ useEffect(() => {
+ // マウント時の実行を防止
+ if (closeCount !== 0)
+ setOpenDetail(false);
+ }, [closeCount])
return (
(後略)
Discussion
失礼します。
と公式ドキュメントにあるように、lifting state up の手法を使うのが最善でオススメです。
useEffect で「マウント時などに暴発する」のを恐れながら親と子のステートを同期させる実装方法と比較したとき、lift state up を使用すると「すべて展開」「すべて折りたたむ」ボタンを押したときに
showAll
,hideAll
関数が実行されるのが明確であり、(実装が進んでコードがさらに複雑になったとしても)バグを作り込むリスクが下げられます。ソースコードは折りたたんでいます
データ構造をこのように定義して、ぼくもチャレンジしてみました。
demo code.