Reactで、割り勘の金額を計算する入力欄を作る方法

​​React​JavaScript,Playwright,TypeScript,フォーム

割り勘の計算は、合計金額と人数と「何円単位で払うか」の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、スマートフォンでの入力、スクリーンリーダーでの読み上げは試していません。扱うのは円の整数の金額だけで、税やサービス料の計算、人によって額を変える割り方は入れていません。

合計金額の欄に 23456、人数は「−」「4 人」「+」、端数の単位は「100 円単位」。その下に「全員が同じ額を払う」として水色の大きな文字で「1人 5,900 円」と「集まるのは 23,600 円(合計より 144 円多い)」、「端数を幹事が払う」として「1人 5,800 円」と「幹事は 6,056 円(ほかに 3 人)」が並んでいる
23,456 円を4人で、100 円単位で割ったときの表示

1人あたりの額は、単位で切り上げるか切り捨てるか

計算の関数は、React と関係のないファイルにまとめました。金額はすべて円の整数で扱います。

ts
// 割り勘の計算。金額はすべて円の整数で扱う

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 に入れておくと、更新し忘れたときに表示がずれます。

tsx
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>
  );
}
css
.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」になります。

​Posted by 86