Reactで、割り勘の金額を計算する入力欄を作る方法
割り勘の計算は、合計金額と人数と「何円単位で払うか」の3つが決まれば、Math.ceil と Math.floor の1行ずつで出せます。React の部品では、この3つだけを state に持ち、1人あたりの額は描画のたびに計算します。金額の欄は type="text" にして、全角の数字で打っても計算できるようにしました。
ここでは、合計金額の欄、人数を増減するボタン、端数の単位を選ぶリストを並べた BillSplitter という部品を作りました。結果は「全員が同じ額を払う」と「端数を幹事が払う」の2通りを並べて出します。
検証環境は 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 で操作しました。IME の変換中の状態は Chrome DevTools Protocol の Input.imeSetComposition で作ったもので、OS の日本語入力で打ったものではありません。Firefox と Safari、スマートフォンでの入力、スクリーンリーダーでの読み上げは試していません。扱うのは円の整数の金額だけで、税やサービス料の計算、人によって額を変える割り方は入れていません。

1人あたりの額は、単位で切り上げるか切り捨てるか
計算の関数は、React と関係のないファイルにまとめました。金額はすべて円の整数で扱います。
// 割り勘の計算。金額はすべて円の整数で扱う
export type EvenSplit = {
perPerson: number; // 1人あたり
collected: number; // 集まる合計
surplus: number; // 合計より多く集まる額
};
// 全員が同じ額を払う。1人あたりは unit 円単位で切り上げる
export function splitEvenly(total: number, people: number, unit: number): EvenSplit {
const perPerson = Math.ceil(total / (people * unit)) * unit;
const collected = perPerson * people;
return { perPerson, collected, surplus: collected - total };
}
export type OrganizerSplit = {
perPerson: number; // 幹事以外の1人あたり
organizer: number; // 幹事が払う額
};
// 幹事以外は unit 円単位で切り捨てた額を払い、残りを幹事が払う
export function splitWithOrganizer(total: number, people: number, unit: number): OrganizerSplit {
const perPerson = Math.floor(total / (people * unit)) * unit;
return { perPerson, organizer: total - perPerson * (people - 1) };
}
「全員が同じ額を払う」では、合計を「人数 × 単位」で割って Math.ceil で切り上げ、単位を掛けます。23,456 円を4人、100 円単位で割ると、1人 5,900 円です。集まるのは 23,600 円で、合計より 144 円多くなります。この多く集まる 144 円を「集まるのは」の行に出して、払う前に分かるようにしています。
「端数を幹事が払う」では、同じ割り算を Math.floor で切り捨てます。幹事以外の3人は 5,800 円ずつ払い、幹事は残りの 6,056 円を払います。こちらは、集まる額が合計とちょうど同じになります。
検証環境で、いくつかの組み合わせを計算した結果です。
| 合計 | 人数 | 単位 | 全員が同じ額 | 余る額 | 幹事以外 | 幹事 |
|---|---|---|---|---|---|---|
| 10,000 円 | 3 人 | 1 円 | 3,334 円 | 2 円 | 3,333 円 | 3,334 円 |
| 10,000 円 | 3 人 | 100 円 | 3,400 円 | 200 円 | 3,300 円 | 3,400 円 |
| 10,000 円 | 3 人 | 1,000 円 | 4,000 円 | 2,000 円 | 3,000 円 | 4,000 円 |
| 23,456 円 | 4 人 | 1,000 円 | 6,000 円 | 544 円 | 5,000 円 | 8,456 円 |
| 500 円 | 5 人 | 1,000 円 | 1,000 円 | 4,500 円 | 0 円 | 500 円 |
1 円単位でも、割り切れなければ、合計ちょうどを全員同じ額で集めることはできません。10,000 円を3人なら、全員が 3,334 円を払うと2円余り、幹事が払う形なら幹事だけが1円多くなります。
表のとおり、単位を大きくすると、余る額や幹事の負担が増えました。23,456 円を4人で 1,000 円単位にすると、幹事は 8,456 円で、ほかの人より 3,456 円多く払います。合計が「人数 × 単位」より小さいと、幹事以外の額は 0 円になり、合計を幹事が1人で払う結果になりました。余る額も、幹事とほかの人の差も、「人数 × 単位」より小さく収まります。合計を 1〜999,999,999 円、人数を 2〜99 人、単位を 1・10・100・500・1,000 円からランダムに選んだ100万組(重なりを除くと 999,998 組)で、次のことを確かめました。
- 全員が同じ額のとき、集まる額は合計以上で、余る額は「人数 × 単位」より小さい。1人あたりを1単位減らすと合計に届かない
- 幹事が払うとき、全員の額を足すと合計にちょうどなり、幹事の額はほかの人以上で、差は「人数 × 単位」より小さい
/ の割り算は小数になるので、誤差で切り上げや切り捨てがずれないかも確かめました。同じ100万組で、% で求めた余りから商を出す整数だけの計算と比べたところ、1人あたりの額はすべて一致しました。
合計金額だけを文字列で持ち、1人あたりの額は state にしない
部品の state は、合計金額の欄の文字列、人数、単位の3つです。1人あたりの額や幹事の額は、この3つから描画のたびに計算します。React の公式ドキュメントにある、冗長な state を避ける原則のとおり、ほかの state から計算できる値を state に入れておくと、更新し忘れたときに表示がずれます。
import { useId, useState } from 'react';
import { splitEvenly, splitWithOrganizer } from './splitBill';
const UNITS = [1, 10, 100, 500, 1000];
const MAX_DIGITS = 9; // 999,999,999 円まで
const MAX_PEOPLE = 99;
const yen = (n: number) => `${n.toLocaleString('ja-JP')} 円`;
// 全角の数字も半角にしてから、数字以外を取り除く
function toDigits(text: string): string {
return text.normalize('NFKC').replace(/\D/g, '').replace(/^0+(?=\d)/, '').slice(0, MAX_DIGITS);
}
export function BillSplitter() {
const id = useId();
const [totalText, setTotalText] = useState('');
const [people, setPeople] = useState(2);
const [unit, setUnit] = useState(100);
const total = Number(toDigits(totalText)); // 空文字なら 0
const even = total > 0 ? splitEvenly(total, people, unit) : null;
const org = total > 0 && people >= 2 ? splitWithOrganizer(total, people, unit) : null;
return (
<div className="bill">
<label htmlFor={`${id}-total`}>合計金額</label>
<div>
<input
id={`${id}-total`}
type="text"
inputMode="numeric"
autoComplete="off"
value={totalText}
onChange={(e) => {
// IME で変換している間は、打った文字をそのまま持つ
const v = e.target.value;
setTotalText((e.nativeEvent as InputEvent).isComposing ? v : toDigits(v));
}}
onCompositionEnd={(e) => setTotalText(toDigits(e.currentTarget.value))}
/>{' '}
円
</div>
<span id={`${id}-people`}>人数</span>
<div className="stepper" role="group" aria-labelledby={`${id}-people`}>
<button type="button" aria-label="1人減らす" disabled={people <= 1} onClick={() => setPeople(people - 1)}>
−
</button>
<span aria-live="polite">{people} 人</span>
<button type="button" aria-label="1人増やす" disabled={people >= MAX_PEOPLE} onClick={() => setPeople(people + 1)}>
+
</button>
</div>
<label htmlFor={`${id}-unit`}>端数の単位</label>
<select id={`${id}-unit`} value={unit} onChange={(e) => setUnit(Number(e.target.value))}>
{UNITS.map((u) => (
<option key={u} value={u}>
{u.toLocaleString('ja-JP')} 円単位
</option>
))}
</select>
<output htmlFor={`${id}-total ${id}-unit`}>
{even ? (
<>
<section>
<h3>全員が同じ額を払う</h3>
<p className="amount">1人 {yen(even.perPerson)}</p>
<p>
集まるのは {yen(even.collected)}
{even.surplus > 0 && `(合計より ${yen(even.surplus)}多い)`}
</p>
</section>
{org && (
<section>
<h3>端数を幹事が払う</h3>
<p className="amount">1人 {yen(org.perPerson)}</p>
<p>
幹事は {yen(org.organizer)}(ほかに {people - 1} 人)
</p>
</section>
)}
</>
) : (
<p>合計金額を入れてください</p>
)}
</output>
</div>
);
}
.bill {
display: grid;
grid-template-columns: max-content 1fr;
gap: 10px 12px;
align-items: center;
max-width: 360px;
}
.bill input {
width: 9em;
font-size: 1rem;
text-align: right;
}
.bill .stepper {
display: flex;
align-items: center;
gap: 12px;
}
.bill .stepper button {
width: 44px;
height: 44px;
font-size: 1.25rem;
}
.bill output {
grid-column: 1 / -1;
}
.bill h3 {
margin: 12px 0 4px;
font-size: 1rem;
}
.bill output p {
margin: 2px 0;
}
.bill .amount {
font-size: 1.5rem;
color: #067a98;
}
人数の欄は入力欄にせず、「−」と「+」のボタンにしました。押せる範囲は1〜99人で、端まで来たボタンは disabled にしています。検証環境で「−」を押し続けると1人で止まり、「+」を押し続けると99人で止まりました。1人のときは「端数を幹事が払う」を出さず、「全員が同じ額を払う」だけを表示します。1人でも単位で切り上げるので、合計 12 円・1人なら、1 円単位では「1人 12 円」、100 円単位では「1人 100 円」と「集まるのは 100 円(合計より 88 円多い)」になりました。
単位を選ぶ select の value は、MDN の HTMLSelectElement.value の説明のとおり文字列です。検証環境でも、1,000 円単位を選ぶと "1000" という文字列でした。そこで、Number() で数に直してから state に入れています。合計金額の欄が空のときは、結果の代わりに「合計金額を入れてください」と出します。
金額の欄は、全角の数字も受け付ける
金額の欄は type="text" にして、inputmode 属性を numeric にしています。toDigits は、打たれた文字列から数字だけを取り出す関数です。最初に String.prototype.normalize() に "NFKC" を渡して、全角の数字を半角にそろえます。そのあと数字以外を取り除き、先頭の 0 を消して、9桁で切ります。
検証環境で、欄に打ったり入れたりした文字列と、欄に残った値は次のとおりでした。
| 打った文字列 | 欄に残った値 |
|---|---|
12,345円(全角) |
12345 |
00500 |
500 |
12345678901(11桁) |
123456789 |
-3000 |
3000 |
マイナスの記号は数字ではないので取り除かれ、-3000 は 3000 になりました。割り勘で負の金額を入れることはないので、エラーにはせずにこのままにしています。桁数を9桁までにしたのは、999,999,999 円より大きな割り勘を想定しないためです。上の100万組の確かめでは、集まる額が Number.isSafeInteger で整数として正しく扱える範囲に収まることも確かめました。
金額を「23,456」のように3桁区切りで欄に表示したいときは、以前の記事「Reactで、金額を3桁区切りで入力できる欄を作る方法」の書き方が使えます。この部品では、欄は数字だけにして、区切りは結果の表示にだけ付けています。
IME で変換している間は、数字を取り出さない
最初に書いた版では、onChange で毎回 toDigits を通していました。この版で IME の変換中の状態を作ると、変換中の「1」が半角の「1」に書き換えられ、「12」を確定すると欄の値は「11212」になりました。そこで、input イベントの isComposing が true の間は打ったままの文字列を state に入れ、onCompositionEnd で数字だけにしています。この直し方は、以前の記事「Reactで、金額を3桁区切りで入力できる欄を作る方法」で詳しく確かめたものと同じです。
この部品では、計算に使う total を、state の文字列を toDigits に通してから数に直しています。そのため、変換中で欄が「12」のままでも、結果には 12 円を4人で割った額(1人 3 円)が出ました。確定すると、欄は「12」になります。





