Reactで、単位を相互に変換する入力欄を作る方法
単位を相互に変換する入力欄は、「最後に打った欄の単位」と「その欄に打ったままの文字列」だけを state に持てば作れます。ほかの欄の値は、描画のたびにその文字列から計算します。こうすると、どの欄から打ち直してもほかの欄が換算し直され、打っている途中の「12.」や「-」も消えずに残ります。
検証環境は 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 で1文字ずつキーを打って、そのたびに全部の欄の値を記録しました。Firefox、Safari、スマートフォンの画面のキーボード、IME で打つ全角の数字は試していません。

単位ごとに、基準の単位との行き来を関数で持つ
どの単位からどの単位へも換算できるように、単位ごとに「基準の単位にする関数」と「基準の単位から戻す関数」を持たせます。長さはメートルを基準にしました。
export type Unit = {
id: string;
label: string;
toBase: (v: number) => number; // この単位の値を、基準の単位の値にする
fromBase: (v: number) => number; // 基準の単位の値を、この単位の値にする
};
// 基準の単位に掛ける数だけで換算できる単位
const scaled = (id: string, label: string, factor: number): Unit => ({
id,
label,
toBase: (v) => v * factor,
fromBase: (v) => v / factor,
});
// 長さ(基準はメートル)
export const lengthUnits: Unit[] = [
scaled('mm', 'ミリメートル(mm)', 0.001),
scaled('cm', 'センチメートル(cm)', 0.01),
scaled('m', 'メートル(m)', 1),
scaled('in', 'インチ(in)', 0.0254),
scaled('ft', 'フィート(ft)', 0.3048),
];
// 温度(基準は摂氏)。掛け算だけでは換算できない
export const temperatureUnits: Unit[] = [
{ id: 'c', label: '摂氏(℃)', toBase: (v) => v, fromBase: (v) => v },
{ id: 'f', label: '華氏(℉)', toBase: (v) => ((v - 32) * 5) / 9, fromBase: (v) => (v * 9) / 5 + 32 },
];
単位が5つあっても、換算の式は単位ごとに1組で済みます。センチメートルからインチへは、toBase でメートルにしてから、インチの fromBase で戻します。
係数は、Unicode の CLDR が公開している単位の換算の定義(units.xml)と照らし合わせました。1フィートは 0.3048 メートル、1インチはその12分の1で 0.0254 メートルです。華氏と摂氏は掛け算だけでは行き来できず、32 を引いてから 5/9 を掛けます。そのため、scaled を使わずに関数を直接書いています。
state には、最後に打った欄と、打ったままの文字列だけを持つ
コンポーネントは次のとおりです。
import { useId, useState } from 'react';
import { formatNumber, parseNumber, type Unit } from './units';
type Edit = { unitId: string; text: string }; // 最後に打った欄の単位と、打ったままの文字列
type Props = { units: Unit[]; initial: Edit };
export function UnitConverter({ units, initial }: Props) {
const [edit, setEdit] = useState<Edit>(initial);
const baseId = useId();
const source = units.find((u) => u.id === edit.unitId)!;
const value = parseNumber(edit.text);
const base = value === null ? null : source.toBase(value);
// 空、「-」「.」「-.」は打っている途中なので、間違いとして扱わない
const invalid = value === null && !/^-?\.?$/.test(edit.text.trim());
return (
<div className="converter">
{units.map((u) => {
const isSource = u.id === edit.unitId;
const text = isSource ? edit.text : base === null ? '' : formatNumber(u.fromBase(base));
const id = `${baseId}-${u.id}`;
return (
<div className="row" key={u.id}>
<label htmlFor={id}>{u.label}</label>
<input
id={id}
type="text"
inputMode="decimal"
autoComplete="off"
value={text}
aria-invalid={isSource && invalid}
onChange={(e) => setEdit({ unitId: u.id, text: e.target.value })}
/>
</div>
);
})}
</div>
);
}
state は edit の1つだけです。どの欄の onChange も、「自分の単位」と「打たれた文字列」で edit を置き換えます。打った欄には edit.text をそのまま出し、それ以外の欄には、そこから計算した値を出します。
欄ごとに state を持たせて、onChange でほかの欄の state もまとめて書き換える作り方もできます。ただ、ほかの欄の値は、打った欄の文字列からいつでも計算できます。React の公式ドキュメントにある、state の構造の選び方でも、props や今ある state から描画中に計算できる情報は state に入れないように書かれています。state を1つにしておくと、欄どうしの値が食い違うことがありません。
検証環境の Chromium で、センチメートルの欄に「12.5」を1文字ずつ打ったときの値です。
"1" → mm="10" cm="1" m="0.01" in="0.393701" ft="0.032808"
"2" → mm="120" cm="12" m="0.12" in="4.724409" ft="0.393701"
"." → mm="120" cm="12." m="0.12" in="4.724409" ft="0.393701"
"5" → mm="125" cm="12.5" m="0.125" in="4.92126" ft="0.410105"
続けてインチの欄を消して「10」と打つと、今度はインチの欄が換算の元になり、センチメートルの欄は 25.4 になりました。温度の欄では、摂氏に「-40」と打つと華氏も -40、華氏に「98.6」と打つと摂氏は 37 でした。
数を1つだけ持つと、小数が打てなくなる
持つものをもっと減らそうとして、基準の単位の数(メートルの値)を1つだけ持ち、打った欄も含めて全部の欄をその数から表示すると、うまくいきません。比べるために、次の版を作りました。
const [base, setBase] = useState<number | null>(initial);
// …
<input
id={id}
type="text"
inputMode="decimal"
value={base === null ? '' : formatNumber(u.fromBase(base))}
onChange={(e) => {
const v = parseNumber(e.target.value);
setBase(v === null ? null : u.toBase(v));
}}
/>
同じように打って、打った欄に残った文字列を比べました。
| 打ったキー | 文字列を持つ版 | 数を1つだけ持つ版 |
|---|---|---|
| センチメートルに「1」「2」「.」「5」 | 12.5 |
125 |
| メートルに「0」「.」「0」「5」 | 0.05 |
5 |
| メートルに「-」「3」 | -3 |
3 |
数だけを持つ版では、「12.」は数にすると 12 なので、欄の文字列はすぐ 12 に戻ります。その後ろに「5」を打つと 125 になりました。メートルの欄では「0.」も数にすると 0 なので、欄はすぐ 0 に戻り、最後に「5」を打つと 5 になりました。また、「-」だけでは数にならないので欄が空になります。打っている途中の文字列は、数では表せません。そのため、打った欄の文字列は state にそのまま残しています。
文字列を数にするときは、Number() だけに任せない
Number() で文字列を数にすると、空の文字列が 0 になります。検証環境の Node.js で試した結果です。
Number("") = 0
Number(" ") = 0
Number("1e3") = 1000
Number("0x10") = 16
これをそのまま使うと、欄を空にしたときに、ほかの欄に 0 が並びます。そこで、数字と小数点と先頭のマイナスだけの文字列のときに数にする関数を書きました。
// 入力された文字列を数にする。数として読めないときは null
export function parseNumber(text: string): number | null {
const t = text.trim();
if (!/^-?(\d+\.?\d*|\.\d+)$/.test(t)) return null;
return Number(t);
}
parseNumber は、""、"-"、"."、"1e3"、"0x10"、"1,234"、全角の "12" のどれにも null を返し、"1." は 1、".5" は 0.5 になりました。null のときは、ほかの欄を空にします。
ただし、"-" や "." は、負の数や小数を打っている途中の文字列です。この段階で入力欄の枠を赤くすると、まだ打ち終えていないのに間違いと言われることになります。コンポーネントの invalid は、空と -、.、-. を除いたうえで、数にできない文字列のときだけ true にしています。センチメートルの欄に「12a」と打つと、その欄だけ aria-invalid="true" になり、ほかの欄は空になりました。
入力欄は type="text" にして、inputMode="decimal" を付けています。MDN の inputmode 属性の説明によると、decimal は数字と小数点のキーボードを出すためのヒントで、マイナスのキーは機種によって出ないことがあります。温度の欄では、画面のキーボードだけでは負の数を打てないことがあります。また、小数点のキーは地域によって「,」のこともあると書かれています。parseNumber は「.」しか小数点として読みません。
計算した値は、桁数を決めてから表示する
換算した値をそのまま文字列にすると、小数の誤差が見えてしまいます。
0.7 cm → mm をそのまま計算: 6.999999999999999
0.1 ft → in をそのまま計算: 1.2000000000000002
そこで、計算した値は Intl.NumberFormat で小数6桁までに丸めてから表示しています。
// 計算の結果を、小数6桁までの文字列にする
const formatter = new Intl.NumberFormat('en-US', {
maximumFractionDigits: 6,
useGrouping: false,
signDisplay: 'negative',
});
export function formatNumber(v: number): string {
return formatter.format(v);
}
この書式で、0.7 センチメートルはミリメートルの欄に 7、0.1 フィートはインチの欄に 1.2 と出ました。オプションは、それぞれ次の理由で付けています(どれも MDN の Intl.NumberFormat のコンストラクターの説明に一覧があります)。
useGrouping: false は、3桁ごとの「,」を付けないためです。付けたままだと 1234567 は 1,234,567 と表示されます。換算された欄をそのまま打ち直そうとしたとき、parseNumber は「,」の入った文字列を数にできません。
signDisplay: 'negative' は、マイナスのゼロに「-」を付けないためです。既定の書式では、-0 も、-0.0000001 を小数6桁に丸めた値も、-0 と表示されました。センチメートルの欄に「-0」と打ったとき、この指定があるので、ほかの欄は 0 になりました。
'en-US' を指定しているのは、小数点を「.」にそろえるためです。検証環境の Node.js で 1.5 を書式にかけると、en-US と ja-JP では 1.5、de-DE と fr-FR では 1,5 になりました。parseNumber は「.」の小数点だけを読むので、表示と読み取りで記号が食い違わないようにしています。
小数6桁で丸めるので、とても小さな値は 0 と表示されます。0.0000004 は 0 になりました。ミリメートルの欄に 0.1 と打ったときも、フィートの欄は 0.000328 で、計算した値の 0.00032808398950131233 より短くなっています。細かい値を扱う単位を並べるときは、桁数を単位ごとに決めてください。





