Reactで、金額を3桁区切りで入力できる欄を作る方法
金額を3桁区切りで入力できる欄は、type="text" の入力欄に、数として持っている値を Intl.NumberFormat で区切って表示すれば作れます。打つたびに区切り直すので、カーソルの位置を戻す処理と、IME で変換している間は区切らない処理を加えます。フォームで送る値は、区切りのない数にします。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、Playwright 1.63.0 です。.tsx を tsc で型チェックしてから esbuild で変換し、Playwright でキーを打って確かめました。IME の変換中の状態は、Chrome DevTools Protocol の Input.imeSetComposition で作ったもので、OS の日本語入力で打ったものではありません。スマートフォンで出るキーボード、Firefox と Safari、スクリーンリーダーでの読み上げは試していません。扱うのは 0 以上の整数(円の金額)だけで、負の数や小数は対象にしていません。

type="number" の欄には「,」を出せない
数を入れる欄なので <input type="number"> を使いたくなりますが、この欄の値に「,」は入りません。検証環境の Chromium で試したところ、value に '1,234' を入れると値は空になり、キーで「1,234」と打つと「,」が入らずに 1234 になりました。
value に "1,234" を入れる → ""
"1,234" とキーで打つ → "1234" valueAsNumber=1234
そこで、欄は type="text" にして、inputMode="numeric" を付けます。MDN の inputmode 属性の説明によると、numeric は 0 から 9 までの数字を入れるためのキーボードを表示するヒントです。入力できる文字を制限するものではないので、数字以外を取り除く処理は自分で書きます。
数字だけを取り出して、Intl.NumberFormat で区切る
文字列の処理は、React と関係のない関数にまとめました。
const yen = new Intl.NumberFormat('ja-JP');
// 全角の数字を半角にし、数字以外を取り除く
export function toDigits(text: string): string {
return text
.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
.replace(/\D/g, '');
}
// 数字の並びを、3桁区切りの文字列にする(先頭の 0 は消える)
export function formatDigits(digits: string): string {
return digits === '' ? '' : yen.format(Number(digits));
}
// 区切った文字列の中で、左から count 個目の数字の直後の位置
export function caretAfterDigits(formatted: string, count: number): number {
if (count === 0) return 0;
let seen = 0;
for (let i = 0; i < formatted.length; i++) {
if (/\d/.test(formatted[i])) seen++;
if (seen === count) return i + 1;
}
return formatted.length;
}
toDigits は、全角の数字を半角に直してから、数字以外の文字(「,」も含む)をすべて取り除きます。区切りは Intl.NumberFormat('ja-JP') の format に任せています。caretAfterDigits は、区切った文字列の中で「左から何個目の数字の直後か」を、文字の位置に直す関数です。たとえば "12,934,567" の中で3個目の数字(9)の直後は、「,」を1つ含むので位置 4 になります。
区切り直しても、カーソルを同じ数字の後ろに戻す
コンポーネントの全体は次のとおりです。値は親が number | null で持ち、value と onChange で受け渡します。
import { useLayoutEffect, useReducer, useRef, useState } from 'react';
import { caretAfterDigits, formatDigits, toDigits } from './digits';
type Props = {
id?: string;
name?: string;
value: number | null;
onChange: (value: number | null) => void;
maxDigits?: number;
};
export function CurrencyInput({ id, name, value, onChange, maxDigits = 12 }: Props) {
const inputRef = useRef<HTMLInputElement>(null);
const caret = useRef<number | null>(null); // 次の描画の後に置くカーソルの位置
const [, rerender] = useReducer((n: number) => n + 1, 0);
const [composing, setComposing] = useState<string | null>(null); // IME で変換中の文字列
const text = value === null ? '' : formatDigits(String(value));
useLayoutEffect(() => {
if (caret.current === null || !inputRef.current) return;
inputRef.current.setSelectionRange(caret.current, caret.current);
caret.current = null;
});
function apply(raw: string, pos: number) {
const all = toDigits(raw);
const digits = all.replace(/^0+(?=\d)/, ''); // 先頭の余分な 0 を消す
if (digits.length > maxDigits) {
caret.current = Math.max(0, pos - 1); // 桁があふれる入力は受け付けない
rerender();
return;
}
// カーソルより左にある数字の数を、区切り直した後も保つ
const left = Math.max(0, toDigits(raw.slice(0, pos)).length - (all.length - digits.length));
const next = digits === '' ? null : Number(digits);
caret.current = caretAfterDigits(formatDigits(digits), left);
if (next === value) rerender(); // 値が同じでも、カーソルを置き直す
onChange(next);
}
function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
const raw = e.target.value;
// 変換中は区切り直さず、打ったままの文字列を出しておく
if ((e.nativeEvent as InputEvent).isComposing) {
setComposing(raw);
return;
}
apply(raw, e.target.selectionStart ?? raw.length);
}
function handleCompositionEnd(e: React.CompositionEvent<HTMLInputElement>) {
const el = e.currentTarget;
setComposing(null);
apply(el.value, el.selectionStart ?? el.value.length);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
const el = e.currentTarget;
const pos = el.selectionStart;
if (pos === null || pos !== el.selectionEnd) return;
// 区切りの「,」を消そうとしたときは、隣の数字を消す
if (e.key === 'Backspace' && el.value[pos - 1] === ',') el.setSelectionRange(pos - 1, pos - 1);
if (e.key === 'Delete' && el.value[pos] === ',') el.setSelectionRange(pos + 1, pos + 1);
}
return (
<>
<input
ref={inputRef}
id={id}
type="text"
inputMode="numeric"
autoComplete="off"
value={composing ?? text}
onChange={handleChange}
onCompositionEnd={handleCompositionEnd}
onKeyDown={handleKeyDown}
/>
{name && <input type="hidden" name={name} value={value ?? ''} />}
</>
);
}
打つたびに文字列を作り直すだけだと、途中に打ったときにカーソルが末尾へ飛びます。冒頭の GIF の下の欄が、その動きです。比べるために作った、カーソルの位置に手を加えない欄では、末尾に打つだけなら問題はありませんでしたが、途中では次のようになりました(「|」はカーソルの位置)。
| 操作 | カーソルを戻さない欄 | カーソルを置き直す欄 |
|---|---|---|
| `1,2 | 34,567` で 9 を打つ | `12,934,567 |
| `1,234 | ,567` で 0 を打つ | `12,340,567 |
| `1,234,5 | 67` で Backspace | `123,467 |
カーソルを戻すために、区切り直す前の文字列で「カーソルの左にある数字の数」を数え、区切り直した文字列の中で、左から同じ数だけ数字を数えた位置を caret という ref に残します。新しい文字列が入力欄に入った後で、useLayoutEffect から setSelectionRange を呼びます。React の useLayoutEffect のリファレンスにあるとおり、useLayoutEffect はブラウザーが画面を描き直す前に実行され、依存配列を省くと描画のたびに実行されます。
if (next === value) rerender(); は、数字以外の文字を打ったときのための行です。この行を消すと、1,2|34 で「a」を打ったときにカーソルが末尾へ移り、1,234| になりました。React の useState のリファレンスにあるとおり、値が変わらないと React は描画を省きます。そのため、カーソルを置き直す useLayoutEffect が動きません。
onKeyDown の処理を外すと、1,|234,567 で Backspace を押してもカーソルが「,」の前へ動くだけで、1|,234,567 で Delete を押しても何も変わりませんでした。handleKeyDown は、先にカーソルを「,」の反対側へ動かして、隣の数字が消えるようにしています。
IME で変換中は、区切り直さない
この節では、IME で全角の数字を打っていて、まだ確定していない状態を Chrome DevTools Protocol で作って確かめました。変換中の文字列を「1」「12」「123」「1234」と伸ばしています。
変換中の処理(composing と onCompositionEnd)を入れる前のコンポーネントでは、次のように値が崩れました。
変換中「1」 → "1|" value=1
変換中「12」 → "112|" value=112
変換中「123」 → "112,123|" value=112123
変換中「1234」 → "1,121,231,234|" value=1121231234
確定 → "1,121,231,234|" value=1121231234
変換中の「1」を半角の「1」に書き換えると、変換中の文字列は、書き換えた「1」の後ろに改めて足されました。こうして「1」「12」…が積み重なり、1234 のつもりが 1121231234 になっています。CDP で作った変換中の状態でイベントを記録すると、値を書き換えるたびに compositionstart がもう一度起きていました。
そこで、input イベントの isComposing が true の間は、打ったままの文字列を composing という state に入れて表示し、区切り直さないようにしました。MDN の InputEvent.isComposing の説明によると、このプロパティは、イベントが compositionstart の後、compositionend の前に起きたかどうかを表します。変換が確定したら、compositionend で数字を取り出して区切り直します。
変換中「1」 → "1|" value=null
変換中「12」 → "12|" value=null
変換中「123」 → "123|" value=null
変換中「1234」 → "1234|" value=null
確定 → "1,234|" value=1234
変換中は全角のまま表示され、確定すると 1,234 になりました。1,2|34 の途中で「5」を変換して確定したときも、12,5|34 になり、カーソルは「5」の後ろに置かれました。
空の欄は null、先頭の 0 は消す
数字を全部消したときは、値を 0 ではなく null にしています。「0円」と「まだ入れていない」を区別するためです。最初に「0」を打ったときは値が 0 になり、続けて「0」「5」と打つと、先頭の余分な 0 が消えて 5 になりました。
桁数の上限は、Number で正しく扱える範囲から決める
値を Number で持っているので、大きすぎる数は正しく扱えません。MDN の Number.MAX_SAFE_INTEGER の説明にあるとおり、JavaScript で安全に扱える整数の最大値は 9007199254740991 です。Node.js で数字の並びを Number にして区切った結果です。
15桁 999999999999999 → 999,999,999,999,999 isSafeInteger: true / BigInt: 999,999,999,999,999
16桁 9007199254740991 → 9,007,199,254,740,991 isSafeInteger: true / BigInt: 9,007,199,254,740,991
16桁 9007199254740993 → 9,007,199,254,740,992 isSafeInteger: false / BigInt: 9,007,199,254,740,993
16桁 9999999999999999 → 10,000,000,000,000,000 isSafeInteger: false / BigInt: 9,999,999,999,999,999
16桁では、9007199254740991 を超えたところで値が変わりました。どんな並びでも正しく扱えるのは15桁までです。この記事では、余裕をみて maxDigits の初期値を 12 にしました。12桁まで入った欄で13桁目を打つと、打った数字は入らず、値は 123456789012 のままでした。もっと大きな整数を扱うなら、MDN は BigInt を使うことを勧めています。
貼り付けた文字列から、数字だけを拾う
toDigits は数字以外をすべて取り除くので、クリップボードから「¥1,234,567円」を Ctrl+V で貼り付けると、1,234,567 になりました。全角の「12345」をまとめて入れたときも 12,345 です。
「-1,500.75」を貼り付けると、マイナスと小数点が取り除かれて 150,075 になりました。マイナスや小数を入れる欄にこのまま使うと、気づかないうちに桁の違う金額になります。
フォームでは、区切りのない数を送る
入力欄に出ているのは 2,500,000 のような区切り付きの文字列です。そこで、表示用の入力欄には name を付けず、name を受け取ったときは <input type="hidden"> に区切りのない数を入れて送るようにしました。MDN の input type="hidden" の説明にあるとおり、hidden の入力欄は画面に表示されませんが、ほかの入力欄と同じように送信されます。
検証に使った画面のフォームの部分です。
const [price, setPrice] = useState<number | null>(1234567);
const [sent, setSent] = useState('');
// …
<form onSubmit={(e) => {
e.preventDefault();
setSent(JSON.stringify(Object.fromEntries(new FormData(e.currentTarget))));
}}>
<label htmlFor="price">金額</label>
<div className="field">
<CurrencyInput id="price" name="price" value={price} onChange={setPrice} />
<span>円</span>
</div>
<button type="submit">送信</button>
</form>
2500000 と打って送信すると、FormData の中身は {"price":"2500000"} になりました。比べるために、描画された表示用の入力欄に name="price_text" を足して FormData を取ると、{"price_text":"2,500,000","price":"2500000"} となり、区切り付きの文字列も一緒に送られました。
ラベルは表示用の入力欄と htmlFor で結びつけているので、Playwright の getByRole('textbox', { name: '金額' }) で、この入力欄が見つかりました。





