Reactで、メンバーをランダムにグループ分けする方法
メンバーをランダムにグループへ分けるときは、名簿を一度シャッフルしてから、グループ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、スマートフォン、スクリーンリーダーでの読み上げは試していません。「前回と同じ人とは組まない」のような条件付きの分け方は入れていません。

混ぜてから順番に配ると、人数の差は1人以内になる
グループ分けの関数は、React と関係のないファイルにまとめました。
// メンバーのグループ分け。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万回くり返したところ、人数の組み合わせは次のようになりました。
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 に入れる
比べるために、グループを描画のたびに計算し、ボタンでは表示するかどうかだけを切り替える版も作りました。
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つずつ打つと、そのたびにグループの顔ぶれが入れ替わりました。
押した直後: ["グループ 1 たぬき・くま・ねこ・うさぎ","グループ 2 りす・ひつじ・いぬ","グループ 3 さる・しか・きつね"]
末尾に空白を足した 1 回目: ["グループ 1 うさぎ・くま・いぬ・さる","グループ 2 ひつじ・りす・きつね","グループ 3 しか・ねこ・たぬき"]
末尾に空白を足した 2 回目: ["グループ 1 いぬ・しか・たぬき・ひつじ","グループ 2 ねこ・くま・きつね","グループ 3 うさぎ・りす・さる"]
末尾に空白を足した 3 回目: ["グループ 1 いぬ・しか・りす・たぬき","グループ 2 ねこ・くま・ひつじ","グループ 3 うさぎ・さる・きつね"]
空白は trim() で取り除かれるので、名簿は変わっていません。それでも、文字を打つと state が変わって部品が描画し直され、そのたびに splitIntoGroups が新しい乱数で呼ばれます。React の公式ドキュメントにある、コンポーネントを純粋に保つ考え方では、同じ入力には同じ結果を返すようにし、変化を起こす処理(副作用)は通常はイベントハンドラの中に書く、とされています。描画中に乱数を使うと、この「同じ入力なら同じ結果」が成り立ちません。
そこで、ボタンで分ける版では、グループを作る処理をボタンの onClick に移し、結果を state に入れました。
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>
);
}
.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 に名前をそのまま使っています。





