Reactで、電話番号を入力しながらハイフンを入れる方法

​​React​libphonenumber-js,Playwright,TypeScript,フォーム

電話番号の入力欄で、打った数字に合わせて「090-1234-5678」のようにハイフンを入れます。どこで区切るかは、ライブラリの libphonenumber-js に決めてもらいます。React の側でやることは、入力のたびに数字だけを取り出して整え直すことと、整え直したあとにカーソルを元の場所に戻すことです。

カーソルを戻さないと、番号の途中を書き直したときに、カーソルが末尾へ飛びます。ハイフンの直後で Backspace を押しても、何も消えません。以下では、この2つが起きる理由と直し方を、Chromium で操作した結果と一緒に示します。

補足

検証環境は Ubuntu 24.04、Node.js 22.22.0、Chromium 153 です。使ったのは React 19.3.1、TypeScript 7.0.2、esbuild 0.28.2、libphonenumber-js 1.13.15、Playwright 1.63.0 です。比べるための版も含めて、.tsx を tsc で型チェックしてから esbuild で変換し、Playwright で Chromium を操作して確かめました。StrictMode を付けた開発ビルドでも同じ操作をしたところ、出力は本番ビルドと同じでした。扱うのは、0 から始まる日本国内の番号だけです。「+81」から始まる国際的な書き方、日本語入力(IME)で変換しながら数字を打ったときの動き、スマートフォンの画面に出るキーボード、Firefox と Safari は試していません。

電話番号の入力欄に 0、9、0、1、2、3、4、5、6、7、8 と1つずつ打つと、4つ目の数字で「090-1」、8つ目で「090-1234-5」のようにハイフンが入り、最後は「090-1234-5678」になる。下の「送る値」には、ハイフンのない数字だけが出る
数字を11回打った様子。押したキーと変わった「送る値」、ハイフンが入ったときの入力欄の枠を、その瞬間だけピンクにしている(キーと回数の表示、カーソルの縦棒は GIF 用に重ねたもの)

どこで区切るかは libphonenumber-js に任せる

日本の電話番号は、先頭の数桁によって区切る位置が変わります。「090」から始まる「090-1234-5678」と、「03」から始まる「03-1234-5678」では、1つ目のハイフンの位置が違います。番号ごとの区切り方を自分で持つのは大変なので、libphonenumber-js を使いました。Google の libphonenumber を JavaScript 向けに小さくしたライブラリです。

使うのは、入力途中の番号を整える formatIncompletePhoneNumber です。README によると、これは new AsYouType(defaultCountry).input(value) と同じ働きをします。2つ目の引数に 'JP' を渡すと、日本国内の書き方で区切ります。数字を1つずつ増やしながら呼んだ結果です。

text
0 → 09 → 090 → 090-1 → 090-12 → 090-123 → 090-1234 → 090-1234-5 → 090-1234-56 → 090-1234-567 → 090-1234-5678
0 → 03 → 03-1 → 03-12 → 03-123 → 03-1234 → 03-1234-5 → 03-1234-56 → 03-1234-567 → 03-1234-5678
0 → 04 → 046 → 0466 → 0466-1 → 0466-12 → 0466-12-3 → 0466-12-34 → 0466-12-345 → 0466-12-3456
0 → 01 → 012 → 0120 → 0120-1 → 0120-12 → 0120-123 → 0120-123-4 → 0120-123-45 → 0120-123-456
0 → 01 → 011 → 011-2 → 011-23 → 011-234 → 011-234-5 → 011-234-56 → 011-234-567 → 011-234-5678
12桁(12文字): ハイフンを含む=false
parseDigits("090-1234ー5678"): 09012345678

番号の先頭に合わせて、区切る位置が変わりました。12桁にすると、ハイフンが1つも入らなくなりました。そこで、この記事のコンポーネントでは、数字を11桁で打ち切ります。

数字だけを取り出すのには、同じライブラリの parseDigits を使いました。検証環境では、全角の数字も半角の数字にして取り出し、全角のハイフンと長音記号は捨てました。

ライブラリの大きさについて、README には、標準の min のメタデータだけで約 80KB と書かれています(コードと合わせると約 145KB)。日本の番号だけを扱うなら、README の Custom metadata の節にある方法で、使う国だけのメタデータを作って小さくできるそうです(筆者は試していません)。

整え直すだけだと、カーソルが末尾へ飛ぶ

まず、入力のたびに数字だけを取り出して、整えた文字列を value に渡すだけの版を作りました。

tsx
import { useState } from 'react';
import { formatIncompletePhoneNumber, parseDigits } from 'libphonenumber-js';

// 入力のたびに数字だけを取り出して、整え直すだけの版
export function NaivePhoneInput({ id }: { id?: string }) {
  const [digits, setDigits] = useState('');
  return (
    <input
      id={id}
      type="tel"
      value={formatIncompletePhoneNumber(digits, 'JP')}
      onChange={(e) => setDigits(parseDigits(e.target.value).slice(0, 11))}
    />
  );
}

数字を末尾に打っていくだけなら、この版でも冒頭の GIF と同じように整います。困るのは、途中を書き直したときです。以下の出力では、カーソルの位置を | で示しています。

text
--- 2. 途中に数字を足す(090-1234-567 の「1」の後ろに 9)
前: "090-1|234-567"
後: "090-1923-4567|"
続けて 8: "090-1923-4567|"

「1」の後ろで 9 を打つと、9 は正しい場所に入りました。しかし、カーソルは末尾へ飛びました。続けて 8 を打つと、11桁を超えるので打ち切られ、何も変わりませんでした。

ハイフンの直後で Backspace を押したときは、もっと分かりにくい動きになりました。

text
--- 3. ハイフンの直後で Backspace(090-1234-5678)
前: "090-1234-|5678"
1回目: "090-1234-5678|"
2回目: "090-1234-567|"

1回目の Backspace では、ブラウザがハイフンを消します。しかし、取り出した数字は変わらないので、整え直すと同じ文字列に戻ります。見た目は何も消えず、カーソルだけが末尾へ飛びました。2回目は末尾の「8」が消えました。消したかった「4」は残ったままです。

カーソルが末尾へ飛ぶのは、ブラウザの決まりどおりの動きです。HTML の仕様では、input の value を書き換えたとき、値が前と違えば、カーソルを末尾へ動かすことになっています。React は、onChange で受け取った値と違う値を value に渡すと、入力欄の値を書き換えます。ブラウザに入った「090-19234-567」のような値を、React が「090-1923-4567」に書き換えるので、カーソルが末尾へ動きます。公式リファレンスでも、入力のたびにカーソルが飛ぶときの対処として、e.target.value と違う値を state に入れることが、原因の1つに挙げられています。この記事では、違う値を入れたうえで、カーソルを自分で戻します。

上の「カーソルの位置を戻す版」は「090-19|23-4567」のように、打った 9 の直後にカーソルがある。下の「整え直すだけの版」は、値は同じ「090-1923-4567」だが、カーソルが末尾にある
「090-1234-567」の「1」の後ろで 9 を打ったあと。上がこの記事の版、下が整え直すだけの版

カーソルより前の数字の数を数えて、同じ場所に戻す

整え直すと、ハイフンの数や位置は変わります。それでも、カーソルより前にある数字の数は変わりません。そこで、onChange の中で「カーソルより前に数字がいくつあるか」を数えておきます。整えたあとの文字列で、同じ数だけ数字を数えた位置にカーソルを置き直します。

tsx
import { useLayoutEffect, useRef, useState, type ChangeEvent } from 'react';
import { formatIncompletePhoneNumber, parseDigits } from 'libphonenumber-js';

const MAX_DIGITS = 11;

// 整えた文字列の中で、n 個目の数字のすぐ後ろの位置を返す
function caretAfterDigits(formatted: string, n: number) {
  if (n === 0) return 0;
  let count = 0;
  for (let i = 0; i < formatted.length; i++) {
    if (/\d/.test(formatted[i]) && ++count === n) return i + 1;
  }
  return formatted.length;
}

type Props = { id?: string; onDigitsChange?: (digits: string) => void };

export function PhoneInput({ id, onDigitsChange }: Props) {
  const ref = useRef<HTMLInputElement>(null);
  // 数字だけの値と、描き直したあとのカーソル位置を一緒に持つ
  const [state, setState] = useState({ digits: '', caret: 0 });
  const formatted = formatIncompletePhoneNumber(state.digits, 'JP');

  // 描き直したあと、ブラウザが画面を描く前にカーソルを戻す
  useLayoutEffect(() => {
    const el = ref.current!;
    if (document.activeElement === el) el.setSelectionRange(state.caret, state.caret);
  }, [state]);

  function handleChange(e: ChangeEvent<HTMLInputElement>) {
    const input = e.target;
    const caret = input.selectionStart ?? input.value.length;
    let digits = parseDigits(input.value);
    // カーソルより前にある数字の数を数えておく
    let before = parseDigits(input.value.slice(0, caret)).length;

    // ハイフンだけを Backspace や Delete で消したときは、その隣の数字を消す
    const inputType = (e.nativeEvent as InputEvent).inputType;
    if (digits === state.digits && inputType === 'deleteContentBackward' && before > 0) {
      digits = digits.slice(0, before - 1) + digits.slice(before);
      before -= 1;
    } else if (digits === state.digits && inputType === 'deleteContentForward') {
      digits = digits.slice(0, before) + digits.slice(before + 1);
    }

    digits = digits.slice(0, MAX_DIGITS);
    before = Math.min(before, digits.length);
    const next = formatIncompletePhoneNumber(digits, 'JP');
    setState({ digits, caret: caretAfterDigits(next, before) });
    onDigitsChange?.(digits);
  }

  return (
    <input
      ref={ref}
      id={id}
      type="tel"
      autoComplete="tel-national"
      value={formatted}
      onChange={handleChange}
    />
  );
}

カーソルを置き直すのは、setSelectionRange です。MDN にあるとおり、始まりと終わりに同じ位置を渡すと、文字を選ばずにカーソルだけをそこへ動かせます。

呼ぶ場所は useLayoutEffect にしました。公式リファレンスによると、useLayoutEffect の処理は、React が画面の要素を書き換えたあと、ブラウザが画面を描く前に動きます。React が値を書き換えてカーソルが末尾へ動いたあと、画面に出る前に戻せます。

途中に数字を足した結果です。9 を打つとカーソルは 9 の直後に残り、続けて 8 を打っても 8 の直後に残りました。11桁を超えた分は、末尾の数字が打ち切られています。

text
--- 2. 途中に数字を足す(090-1234-567 の「1」の後ろに 9)
前: "090-1|234-567"
後: "090-19|23-4567"
続けて 8: "090-198|2-3456"

この打ち切り方では、末尾の「7」が黙って消えます。11桁になったら新しい数字を受け付けないほうがよい場面もありますが、その書き方は試していません。

ハイフンだけを消したときは、隣の数字を消す

カーソルを戻すだけでは、ハイフンの直後の Backspace は、まだ思ったとおりに動きません。上のコードから、ハイフンを消したときの処理だけを外した版の結果です。

text
--- 3. ハイフンの直後で Backspace(090-1234-5678)
前: "090-1234-|5678"
1回目: "090-1234|-5678"
2回目: "090-123|5-678"

1回目の Backspace では、カーソルがハイフンの前へ1つ動くだけで、何も消えませんでした。2回目でやっと「4」が消えました。使えなくはありませんが、1回目に何も起きないように見えます。

そこで、ハイフンだけが消えたことを見分けて、その隣の数字を消すようにしました。見分けるのは2つです。1つは、取り出した数字が前と同じであることです。もう1つは、入力の種類です。公式リファレンスにある、React のイベントオブジェクトの説明のとおり、onChange で受け取るイベントの nativeEvent には、ブラウザのイベントが入っています。入力欄で Backspace と Delete キーを押したとき、検証環境の Chromium で届いたイベントは、どちらも InputEvent でした。その inputType は、Backspace なら deleteContentBackward、Delete キーなら deleteContentForward でした。

text
===== 入力イベントの種類(#phone で Backspace と Delete を押す)
InputEvent inputType=deleteContentBackward
InputEvent inputType=deleteContentForward

ハイフンを消したときの処理を入れた版の結果です。Backspace は1回目で「4」を消し、Delete キーはハイフンの直前から「5」を消しました。

text
--- 3. ハイフンの直後で Backspace(090-1234-5678)
前: "090-1234-|5678"
1回目: "090-123|5-678"
2回目: "090-12|56-78"
--- 4. 途中で Delete(ハイフンの直前)
前: "090-1234|-5678"
後: "090-1234|-678"

整え直すだけの版では、Delete キーを押すと、何も消えずにカーソルが末尾へ飛びました。

数字以外のキーでカーソルが飛ばないように、state を毎回新しくする

カーソルの位置を state に入れず、useRef に置く書き方も考えられます。state には数字だけを持たせ、useLayoutEffect の依存配列を数字にする書き方です。この書き方で試すと、数字以外のキーを打ったときに、カーソルが末尾へ飛びました。

text
--- 5. 途中で数字以外のキー(a)を打つ
前: "090-1|234-567"
後: "090-1234-567|"

「a」を打っても、取り出した数字は変わりません。同じ値で state を更新すると、React は描き直しを省くことがあります。描き直しが起きたとしても、依存配列の数字が前と同じなので、useLayoutEffect は動きません。一方で、React は入力欄の値を「090-1a234-567」から元の値へ戻すので、カーソルは末尾へ動きます。

この記事のコンポーネントは、数字とカーソルの位置を1つのオブジェクトにして、毎回新しいオブジェクトで state を更新しています。useState の公式リファレンスにあるとおり、React は Object.is で前の値と比べます。新しいオブジェクトは前の値と別のものなので、毎回描き直されます。依存配列の state も毎回変わるので、useLayoutEffect も毎回動きます。同じ操作で、カーソルは「1」の後ろに残りました。

text
--- 5. 途中で数字以外のキー(a)を打つ
前: "090-1|234-567"
後: "090-1|234-567"

入力欄の属性と、送る値

入力欄は type="tel" にしました。MDN の <input type="tel"> のページによると、スマートフォンのブラウザでは電話番号向けのキーボードが出ることがあります。一方で、メールアドレス(type="email")などと違って、送信の前にブラウザが形を自動で確かめることはありません。電話番号の書き方は国ごとにさまざまだからです。

autoComplete には tel-national を渡しました。MDN の autocomplete 属性のページでは、国番号を含まない電話番号を表す値とされています。ブラウザに保存された番号で自動入力したときの動きは、試していません。

まとめて入れた文字列も、数字だけを取り出して整えます。全角の「090-1234-5678」を、貼り付けと同じように1回で入れた結果です。

text
--- 6. 全角の文字列をまとめて入れる(貼り付けと同じ insertText)
後: "090-1234-5678|"

フォームから送ったり、データベースに入れたりするときは、ハイフンを入れた文字列ではなく、数字だけの値を使うほうが扱いやすくなります。このコンポーネントでは、onDigitsChange で数字だけの値を親に渡しています。冒頭の GIF の「送る値」は、親がこれを表示したものです。

tsx
const [digits, setDigits] = useState('');
// ...
<label htmlFor="phone">電話番号</label>
<PhoneInput id="phone" onDigitsChange={setDigits} />
<p data-testid="digits">送る値:{digits}</p>

​Posted by 86