Reactで、メンバーをランダムにグループ分けする方法

​​React​JavaScript,Playwright,TypeScript

メンバーをランダムにグループへ分けるときは、名簿を一度シャッフルしてから、グループ1、2、3…と順番に1人ずつ配ります。こうすると、どのグループも人数の差が1人以内になります。React の部品では、分けた結果を描画中に計算せず、ボタンを押したときにだけ作って state に入れます。描画中に乱数を使うと、文字を1つ打っただけでグループが入れ替わってしまうからです。

ここでは、メンバーを1行に1人ずつ書く欄と、「グループの数」か「1グループの人数」を選ぶ欄を並べた GroupMaker という部品を作りました。

補足

検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは react / react-dom 19.3.1、@types/react 19.3.0、typescript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、StrictMode で包んだ開発用のビルドを Playwright で操作しました。ボタンで分ける版を Playwright で操作するときは、結果が毎回同じになるように、Math.random をシードを固定した乱数に置き換えています。Firefox と Safari、スマートフォン、スクリーンリーダーでの読み上げは試していません。「前回と同じ人とは組まない」のような条件付きの分け方は入れていません。

メンバーの欄に動物の名前が並び、最後の行に「ねこ」がもう一度書かれている。その下に「10 人」、分け方は「1グループの人数を決める」、人数は「4 人まで」、ボタンは「もう一度分ける」。結果は水色の枠で3つ並び、「グループ 1(4 人)ねこ・いぬ・さる・きつね」「グループ 2(3 人)しか・たぬき・りす」「グループ 3(3 人)ひつじ・うさぎ・くま」
10人を「4人まで」で分けたときの表示。同じ名前の2回目は数えていない

混ぜてから順番に配ると、人数の差は1人以内になる

グループ分けの関数は、React と関係のないファイルにまとめました。

ts
// メンバーのグループ分け。React とは関係のない関数だけを置く

// 1行に1人。前後の空白を取り、空の行と同じ名前の2回目以降を除く
export function parseMembers(text: string): string[] {
  const names = text.split('\n').map((line) => line.trim()).filter((name) => name !== '');
  return [...new Set(names)];
}

// 配列を並べ替えた新しい配列を返す(Fisher–Yates のシャッフル)
export function shuffle<T>(items: readonly T[], rand: () => number = Math.random): T[] {
  const a = [...items];
  for (let i = a.length - 1; i > 0; i--) {
    const j = Math.floor(rand() * (i + 1));
    [a[i], a[j]] = [a[j], a[i]];
  }
  return a;
}

// count 個のグループに、人数の差が1人以内になるように配る
export function splitIntoGroups<T>(items: readonly T[], count: number, rand: () => number = Math.random): T[][] {
  const groups: T[][] = Array.from({ length: count }, () => []);
  shuffle(items, rand).forEach((item, i) => {
    groups[i % count].push(item);
  });
  return groups;
}

// 1グループの人数の上限から、グループの数を決める
export function countForSize(total: number, size: number): number {
  return Math.ceil(total / size);
}

parseMembers は、欄に書かれた文字列を名前の配列にします。trim() で前後の空白を取り、空の行を除いたあと、Set に入れて同じ名前を1つにまとめます。検証環境で " ねこ \n\nいぬ\nねこ\n \nうさぎ\n" を渡すと、結果は ["ねこ","いぬ","うさぎ"] でした。全角の空白だけの行も、空の行として除かれています。

splitIntoGroups は、名簿を Fisher–Yates のシャッフルで混ぜてから、i % count 番目のグループに1人ずつ入れます。10人を3グループにすると、人数は 4・3・3 です。配る順番が決まっているので、人数の多いグループは必ず前に来ます(11人を3グループにすると 4・4・3 でした)。

「1グループの人数」で分けるときは、countForSize で先にグループの数を決めてから、同じ関数で配ります。10人を「4人まで」で分けると、グループは Math.ceil(10 / 4) で3つになり、人数は 4・3・3 です。名簿の先頭から4人ずつ切り分けると 4・4・2 になるので、それより人数がそろいます。検証環境での結果は次のとおりでした。

人数 1グループの人数 グループの数 各グループの人数
10 人 4 人まで 3 4・3・3
9 人 4 人まで 3 3・3・3
7 人 3 人まで 3 3・2・2

9人を「4人まで」で分けると、4人のグループは1つもできず、3人ずつになります。「4人まで」は上限で、全員が4人になるとは限りません。

1〜100人を1〜100グループ(人数以下)に分けた 5,050 通りで、全員がちょうど1回ずつどこかに入っていて、人数の差が1人以内になることを確かめました。2〜100人を「2〜10人まで」で分けた 891 通りでも、どのグループも上限を超えず、グループを1つ減らすと上限に収まらないことを確かめています。

1人ずつグループを選ばせると、人数がそろわない

ランダムに分けるだけなら、1人ずつ Math.floor(Math.random() * count) でグループを選ぶ書き方もあります。ただ、この書き方では人数がそろいません。検証環境で、A〜F の6人を2グループに分けるのを24万回くり返したところ、人数の組み合わせは次のようになりました。

text
3対3: 75058 / 4対2: 56318 / 2対4: 56148 / 5対1: 22708 / 1対5: 22424 / 6対0: 3711 / 0対6: 3633

3人ずつに分かれたのは 75,058 回で、全体の3分の1もありません。全員が片方のグループに入ってしまった回も、合わせて 7,344 回ありました。

splitIntoGroups で同じように24万回分けると、必ず3人ずつになります。グループ1の顔ぶれは、6人から3人を選ぶ20通りがすべて出て、どれも 11,887〜12,153 回でした。偏りがなければ1通りあたり1万2千回なので、ほぼ同じ回数です。A と B が同じグループになったのは 95,998 回、A と F は 96,183 回で、どちらも期待される 96,000 回(5回に2回)に近い値でした。

シャッフルを sort(() => Math.random() - 0.5) で書くと、並び方が偏ります。この偏りは、以前の記事「Reactで、カードをめくる神経衰弱ゲームを作る方法」で Fisher–Yates と比べて確かめたので、ここでは Fisher–Yates だけを使っています。

分けた結果は、ボタンを押したときに state に入れる

比べるために、グループを描画のたびに計算し、ボタンでは表示するかどうかだけを切り替える版も作りました。

tsx
import { useState } from 'react';
import { parseMembers, splitIntoGroups } from './groups';

// 比べるための版:グループを描画のたびに計算している
export function GroupMakerV1() {
  const [text, setText] = useState('');
  const [shown, setShown] = useState(false);
  const members = parseMembers(text);
  const groups = splitIntoGroups(members, 3);

  return (
    <div className="groups">
      <textarea rows={8} value={text} onChange={(e) => setText(e.target.value)} />
      <button type="button" onClick={() => setShown(true)}>
        グループに分ける
      </button>
      {shown && (
        <ol className="result">
          {groups.map((group, i) => (
            <li key={i}>
              <h3>グループ {i + 1}</h3>
              <ul>
                {group.map((name) => (
                  <li key={name}>{name}</li>
                ))}
              </ul>
            </li>
          ))}
        </ol>
      )}
    </div>
  );
}

この版で10人を分けたあと、メンバーの欄の最後に空白を1つずつ打つと、そのたびにグループの顔ぶれが入れ替わりました。

text
押した直後: ["グループ 1 たぬき・くま・ねこ・うさぎ","グループ 2 りす・ひつじ・いぬ","グループ 3 さる・しか・きつね"]
末尾に空白を足した 1 回目: ["グループ 1 うさぎ・くま・いぬ・さる","グループ 2 ひつじ・りす・きつね","グループ 3 しか・ねこ・たぬき"]
末尾に空白を足した 2 回目: ["グループ 1 いぬ・しか・たぬき・ひつじ","グループ 2 ねこ・くま・きつね","グループ 3 うさぎ・りす・さる"]
末尾に空白を足した 3 回目: ["グループ 1 いぬ・しか・りす・たぬき","グループ 2 ねこ・くま・ひつじ","グループ 3 うさぎ・さる・きつね"]

空白は trim() で取り除かれるので、名簿は変わっていません。それでも、文字を打つと state が変わって部品が描画し直され、そのたびに splitIntoGroups が新しい乱数で呼ばれます。React の公式ドキュメントにある、コンポーネントを純粋に保つ考え方では、同じ入力には同じ結果を返すようにし、変化を起こす処理(副作用)は通常はイベントハンドラの中に書く、とされています。描画中に乱数を使うと、この「同じ入力なら同じ結果」が成り立ちません。

そこで、ボタンで分ける版では、グループを作る処理をボタンの onClick に移し、結果を state に入れました。

tsx
import { useId, useState } from 'react';
import { countForSize, parseMembers, splitIntoGroups } from './groups';

type Mode = 'count' | 'size';
type Result = { key: string; groups: string[][] };

const NUMBERS = [2, 3, 4, 5, 6, 7, 8, 9, 10];

export function GroupMaker() {
  const id = useId();
  const [text, setText] = useState('');
  const [mode, setMode] = useState<Mode>('count');
  const [n, setN] = useState(2);
  const [result, setResult] = useState<Result | null>(null);

  const members = parseMembers(text);
  const wanted = mode === 'count' ? n : countForSize(members.length, n);
  const count = Math.min(wanted, members.length); // 人数より多いグループは作らない
  const key = JSON.stringify([members, count]);
  const stale = result !== null && result.key !== key;

  function handleSplit() {
    setResult({ key, groups: splitIntoGroups(members, count) });
  }

  return (
    <div className="groups">
      <label htmlFor={`${id}-members`}>メンバー(1行に1人)</label>
      <textarea id={`${id}-members`} rows={8} value={text} onChange={(e) => setText(e.target.value)} />
      <p className="hint">{members.length} 人</p>

      <fieldset>
        <legend>分け方</legend>
        <label>
          <input type="radio" name={`${id}-mode`} checked={mode === 'count'} onChange={() => setMode('count')} />
          グループの数を決める
        </label>
        <label>
          <input type="radio" name={`${id}-mode`} checked={mode === 'size'} onChange={() => setMode('size')} />
          1グループの人数を決める
        </label>
      </fieldset>

      <label htmlFor={`${id}-n`}>{mode === 'count' ? 'グループの数' : '1グループの人数(最大)'}</label>
      <select id={`${id}-n`} value={n} onChange={(e) => setN(Number(e.target.value))}>
        {NUMBERS.map((v) => (
          <option key={v} value={v}>
            {mode === 'count' ? `${v} グループ` : `${v} 人まで`}
          </option>
        ))}
      </select>

      <button type="button" disabled={members.length < 2} onClick={handleSplit}>
        {result ? 'もう一度分ける' : 'グループに分ける'}
      </button>

      {stale && <p className="hint">メンバーか分け方が変わりました。ボタンを押すと分け直します。</p>}
      {result && (
        <ol className={stale ? 'result stale' : 'result'}>
          {result.groups.map((group, i) => (
            <li key={i}>
              <h3>
                グループ {i + 1}({group.length} 人)
              </h3>
              <ul>
                {group.map((name) => (
                  <li key={name}>{name}</li>
                ))}
              </ul>
            </li>
          ))}
        </ol>
      )}
    </div>
  );
}
css
.groups {
  display: grid;
  gap: 8px;
  max-width: 420px;
}
.groups textarea {
  font-size: 1rem;
}
.groups .hint {
  margin: 0;
  color: #555;
  font-size: 0.875rem;
}
.groups fieldset {
  display: grid;
  gap: 4px;
}
.groups select,
.groups button {
  font-size: 1rem;
  min-height: 44px;
}
.groups .result {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
  gap: 8px;
  padding: 0;
  list-style: none;
}
.groups .result > li {
  padding: 8px;
  border: 2px solid #00c8e8;
  border-radius: 8px;
}
.groups .result h3 {
  margin: 0 0 4px;
  font-size: 0.875rem;
  color: #067a98;
}
.groups .result ul {
  margin: 0;
  padding-left: 1.2em;
}
.groups .result.stale {
  opacity: 0.4;
}

この版で10人を「4人まで」で分けてから、欄の最後に空白を打っても、グループは変わりませんでした。分け方を「グループの数を決める」に切り替えて、また「1グループの人数を決める」に戻しても、結果は同じままです。

人数や、人数から決まるグループの数は、state にせずに描画のたびに計算しています。名簿の文字列から毎回同じ値が出るので、描画中に計算しても問題ありません。state に入れるのは、ボタンを押したときにしか決まらない結果だけです。

メンバーを書き換えたら、前の結果を薄くする

結果を state に入れると、メンバーを書き足しても、表示はボタンを押すまで前のままです。そこで、結果を作ったときの名簿とグループの数を key という文字列(リストの key とは別のものです)にして、結果と一緒に持ち、今の key と違うときは結果を薄く表示して、「メンバーか分け方が変わりました。ボタンを押すと分け直します。」と出すようにしました。

検証環境で、10人を分けたあとに「こあら」を書き足すと、人数の表示は「11 人」になり、このメッセージが出て、結果の ol に stale クラスが付きました。グループの顔ぶれは前のままです。「もう一度分ける」を押すと、11人が 4・4・3 人に分かれ、メッセージは消えました。

メンバーが0人か1人のときは、ボタンを disabled にしています。グループの数は人数より多くしないので、3人を「5 グループ」で分けると、1人ずつの3グループになりました。グループの中の名前は parseMembers で重ならないようにしてあるので、リストの key に名前をそのまま使っています。

​Posted by 86