😽

Reactで作った3軸の表に「すべて展開/折りたたむ」ボタンを作る

2023/11/26に公開2

以下のような3軸の表の各行を、まとめて展開/折りたたむボタンの実装方法について、備忘録がてら残しておきます。
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

ピン留めされたアイテム
Honey32Honey32

失礼します。

コンポーネント間で state を共有する

2 つのコンポーネントの state を常に同時に変更したいという場合があります。これを行うには、両方のコンポーネントから state を削除して最も近い共通の親へ移動し、そこから state を props 経由でコンポーネントへ渡します。これは state のリフトアップ (lifting state up) として知られているものであり、React コードを書く際に行う最も一般的な作業のひとつです。

https://ja.react.dev/learn/sharing-state-between-components

と公式ドキュメントにあるように、lifting state up の手法を使うのが最善でオススメです。

useEffect で「マウント時などに暴発する」のを恐れながら親と子のステートを同期させる実装方法と比較したとき、lift state up を使用すると「すべて展開」「すべて折りたたむ」ボタンを押したときに showAll, hideAll 関数が実行されるのが明確であり、(実装が進んでコードがさらに複雑になったとしても)バグを作り込むリスクが下げられます。

コンポーネントのロジックはできるだけコンポーネントが返す JSX の中で表現する。何かを「変える」必要がある場合、通常はイベントハンドラで行う。最終手段として useEffect を使用する。

https://ja.react.dev/learn/keeping-components-pure#recap

ソースコードは折りたたんでいます
Tableの実装部分
// すべてを開いている状態のオブジェクトを作成する関数
// (すべてのidに対して、usersShown[id] が true となるようなオブジェクト)
const createAllOpenState = (users) => Object.fromEntries(Users.map(user => [user.id, true]))

const Table = () => {
  // 初期状態では、全てを開いた状態
  const [usersShown, setUsersShown] = useState(() => createAllOpenState(Users));

  // 指定した id のユーザーデータの開閉状態を newValue で上書きする
  const toggleUserShown = (id, newValue) => {
    setUsersShown(prev => ({ ...prev, [id]: newValue }));
  }

  // すべてを開く(すべてのidに対して、usersShown[id] を true にする)
  const showAll = () => {
    setUsersShown(createAllOpenState(Users));
  }

  // すべてを閉じる
  const hideAll = () => {
    setUsersShown({});
  }


  return (
    <div>
      <button onClick={showAll}>
        すべて展開
      </button>
      <button onClick={hideAll}>
        すべて折りたたむ
      </button>
      <table>
        <thead>
           {/* 省略 */}
        </thead>
        <tbody>
          {Users.map(user => (
            <RowsEachUser 
               key={user.id}
               open={!!usersShown[user.id]} // true | false | undefeind なので Boolean に直す
               onOpenChange={(newValue) => toggleUserShown(user.id, newValue)}
               user={user}
            />
             ))}
        </tbody>
      </table>
    </div>
  );
RowsEachUserの実装部分
- const RowsEachUser = ({ user }) => {
-    // 行の開閉を管理する
-    const [openDetail, setOpenDetail] = useState(true);
+ const RowsEachUser = ({ user, open, onOpenChange }) => {
+   // openDetail, setOpenDetail のかわりに、open, onOpenChange を使う
    // 以下略....
nap5nap5

データ構造をこのように定義して、ぼくもチャレンジしてみました。

export type User = {
  id: number
  user_name: string
}

export type StudyRecord = {
  id: number
  user_id: number
  week: string
  study_time: number
  result: number
  retest_times: number
}

export type Item = User & {
  study_records: StudyRecord[]
}

demo code.

https://codesandbox.io/p/devbox/back-demo-yczg3q