Reactで、入力した文字数を数えて上限を知らせる方法
入力した文字数は、Intl.Segmenter で見た目の1文字ごとに数えると、絵文字も1文字になります。数えた結果から「あと○文字」を出し、上限が近づいたら色を変え、超えたら setCustomValidity でフォームの送信をブラウザに止めてもらいます。React で持つ state は、入力欄の文字列1つだけです。
CharCountTextarea という部品を作り、打ったとき、文字列を一度に入れたとき、送信したときの動きを確かめました。比べるために、maxLength で入力そのものを止める書き方も試しています。
検証環境は Ubuntu 24.04、Node.js 22.22.0、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 で変換し、Playwright で Chromium を操作しました。文字列を一度に入れる操作は、Playwright の insertText で再現しました(クリップボードからの実際の貼り付けは試していません)。日本語の変換中の文字は、実際の IME ではなく、Chrome DevTools Protocol の Input.imeSetComposition で再現しています。スクリーンリーダーでの読み上げ、スマートフォンのキーボード、Firefox と Safari は試していません。

GIF のピンクの枠、操作の回数、キーの表示は、撮影のために重ねたものです。撮影のときは文字数の表示を大きくし、送信ボタンを隠しています。打つ操作と貼り付けは、どちらも insertText で文字を入れて再現しました。ピンクの枠は、表示の色と文言が変わった瞬間だけ出しています。
value.length では、絵文字が2文字以上に数えられる
文字列の length は、文字の数ではなく、UTF-16 のコード単位の数です。MDN の String.length の説明にあるとおり、絵文字や一部の漢字では、見た目の文字数と合いません。
見た目の1文字(書記素クラスター)で数えるには、Intl.Segmenter に granularity: 'grapheme' を渡して文字列を区切り、区切りの数を数えます。この記事では、次の countChars 関数を使います。Intl.Segmenter は、countChars を呼ぶたびに作るのではなく、ファイルの先頭で1つだけ作っています。
// 見た目の1文字(書記素クラスター)ごとに数える
const segmenter = new Intl.Segmenter('ja', { granularity: 'grapheme' });
export function countChars(text: string): number {
let n = 0;
for (const _ of segmenter.segment(text)) n++;
return n;
}
検証環境の Chromium で、length と countChars の結果を比べました。
| 文字列 | length | countChars |
|---|---|---|
| ねこの手 | 4 | 4 |
| 𠮷野家(「𠮷」は「つちよし」) | 4 | 3 |
| 👍🏽(肌の色を付けた絵文字) | 4 | 1 |
| 👨👩👧(家族の絵文字) | 8 | 1 |
| 「か」+結合用の濁点 U+3099(見た目は「が」) | 2 | 1 |
| 🇯🇵(国旗) | 4 | 1 |
| a、改行、b | 3 | 3 |
ふだんのひらがなや漢字だけなら、どちらで数えても同じです。違いが出るのは、絵文字、「𠮷」のような一部の漢字、結合用の濁点を使った文字です。改行は、どちらでも1文字に数えられました。
「あと○文字」を出す部品
CharCountTextarea は、フォームで送るときの名前(name)、見出し(label)、上限の文字数(max)、残りが何文字以下になったら知らせるか(warnRemaining)を受け取ります。
import { useEffect, useId, useRef, useState } from 'react';
import { countChars } from './countChars';
type Props = {
name: string;
label: string;
max: number;
// 残りがこの文字数以下になったら、表示を変えて知らせる
warnRemaining?: number;
};
export function CharCountTextarea({ name, label, max, warnRemaining = 10 }: Props) {
const id = useId();
const counterId = `${id}-counter`;
const ref = useRef<HTMLTextAreaElement>(null);
const [text, setText] = useState('');
const count = countChars(text);
const remaining = max - count;
const status = remaining < 0 ? 'over' : remaining <= warnRemaining ? 'near' : 'ok';
// 上限を超えている間は、フォームの送信をブラウザに止めてもらう
useEffect(() => {
ref.current?.setCustomValidity(status === 'over' ? `${max}文字以内で入力してください` : '');
}, [status, max]);
return (
<div className="field">
<label htmlFor={id}>{label}</label>
<textarea
ref={ref}
id={id}
name={name}
rows={3}
value={text}
onChange={(e) => setText(e.target.value)}
aria-describedby={counterId}
aria-invalid={status === 'over'}
/>
<p id={counterId} className={`counter is-${status}`}>
{status === 'over' ? `${-remaining}文字オーバー` : `あと${remaining}文字`}({count}/{max})
</p>
{/* 読み上げは、状態が変わったときだけ */}
<p className="visually-hidden" aria-live="polite">
{status === 'near' && `残り${warnRemaining}文字以下になりました`}
{status === 'over' && `${max}文字を超えています`}
</p>
</div>
);
}
textarea には value と onChange を渡し、入力した文字列を state に入れています。React の公式リファレンスにある textarea の説明のとおり、value を渡すときは、state を更新する onChange も一緒に渡します。文字数、残り、状態(ok・near・over)は、その state から描画のたびに計算するので、別の state にはしていません。
状態ごとに、文字数の表示の色を変えています。上限を超えたときは、入力欄の枠も赤くします。
.counter { margin: 4px 0 12px; font-size: 14px; text-align: right; }
.counter.is-ok { color: #5b6870; }
.counter.is-near { color: #8a5a00; font-weight: bold; }
.counter.is-over { color: #b3261e; font-weight: bold; }
textarea[aria-invalid="true"] { border-color: #b3261e; outline-color: #b3261e; }
上限を20文字、warnRemaining を5にして、検証環境で順に入力した結果です。表示の色は、ok が灰色、near が太字の茶色、over が太字の赤です。
| 操作 | 文字数の表示 | 状態 |
|---|---|---|
| 何も入力していない | あと20文字(0/20) | ok |
| 「ねこの手も借りたい」(9文字)を打つ | あと11文字(9/20) | ok |
| さらに「ほどいそがしい日です」(10文字)を打つ | あと1文字(19/20) | near |
| さらに「。」を打つ | あと0文字(20/20) | near |
| さらに「👍🏽」を入れる | 1文字オーバー(21/20) | over |
| Backspace を1回押す | あと0文字(20/20) | near |
肌の色を付けた「👍🏽」も、1文字として数えられています。Backspace を1回押すと、「👍🏽」がまるごと消え、20文字に戻りました。
入力欄の aria-describedby には、文字数の表示の id を渡しています。MDN の aria-describedby の説明にあるとおり、aria-describedby は、要素と、その要素を説明する別の要素を結びつける属性です。ここでは、文字数の表示を入力欄の説明として結びつけています。検証では、入力欄の aria-describedby が指す要素の文字は「あと0文字(20/20)」で、そのときの表示(Backspace を押したあと)と同じでした。
読み上げは、状態が変わったときだけにする
文字数の表示そのものを aria-live にすると、1文字打つたびに中身が変わります。そこで、読み上げ用の見えない要素を別に置き、中身を「残り5文字以下になりました」「20文字を超えています」の2通りだけにしました。状態が変わらないかぎり中身も変わらないので、打つたびに読み上げられることはないはずです。
MDN の aria-live の説明によると、aria-live を付けた要素の中身が変わると、支援技術がその内容を読み上げます。polite にすると、ふつうは、いまの操作を中断せずに読み上げます。
上の表の操作(全部で21文字の入力と、Backspace 1回)のあいだに、読み上げ用の要素の中身が何回変わったかを、MutationObserver で数えました。
読み上げ用の領域の中身が変わった回数: 3
変わったあとの中身: ["残り5文字以下になりました","20文字を超えています","残り5文字以下になりました"]
中身が変わったのは、near になったとき、over になったとき、near に戻ったときの3回だけでした。スクリーンリーダーで実際にどう読まれるかは試していません。
上限を超えたら、送信をブラウザに止めてもらう
上限を超えている間は、useEffect の中で setCustomValidity にエラーの文言を渡し、超えていないときは空の文字列を渡しています。MDN の HTMLTextAreaElement.setCustomValidity() の説明にあるとおり、空の文字列を渡すと、独自のエラーはなくなります。
検証では、送信のときに FormData の中身を書き出す画面を用意しました。
import { useState, type FormEvent } from 'react';
import { CharCountTextarea } from './CharCountTextarea';
function App() {
const [sent, setSent] = useState('');
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
setSent(JSON.stringify(Object.fromEntries(data)));
};
return (
<form onSubmit={handleSubmit}>
<CharCountTextarea name="comment" label="ひとこと" max={20} warnRemaining={5} />
<button type="submit">送信</button>
<p id="sent">{sent}</p>
</form>
);
}
上の表の続きで「ありがとう」を足し、25文字(5文字オーバー)にして送信ボタンを押すと、checkValidity() は false になり、送信の処理は呼ばれませんでした。フォーカスは入力欄に移りました。
form.checkValidity(): false
validationMessage: 20文字以内で入力してください
送信された値: ""
19文字に直してから送信ボタンを押すと、{"comment":"ねこの手も借りたいほどいそがしい日です"} が送られました。送信を止めるかどうかも、表示と同じ countChars で数えた文字数で決めるので、表示と送信の判断がずれません。
maxLength で入力を止めると、絵文字が途中で切れることがある
上限を超える入力をさせたくない場合は、textarea に maxLength を付ける書き方もあります。MDN の maxlength 属性の説明によると、maxlength の長さは UTF-16 のコード単位で数えます。つまり、length と同じ数え方です。
maxLength={10} の textarea だけを置いた画面で試した結果です。
| 操作 | 入力欄に残った文字列 | length | countChars |
|---|---|---|---|
| 13文字を1文字ずつ打つ | あいうえおかきくけこ | 10 | 10 |
13文字を一度に入れる(insertText) |
あいうえおかきくけこ | 10 | 10 |
| 「あいうえお👍🏽👍🏽」を一度に入れる | あいうえお👍🏽 | 9 | 6 |
| 8文字のあとに「👨👩👧」を入れる | あいうえおかきく👨 | 10 | 9 |
| 改行を含む9文字のあとに4文字を入れる | あいうえ(改行)かきくけさ | 10 | 10 |
ひらがなだけなら、1文字ずつ打っても、insertText で一度に入れても、ちょうど10文字で止まりました。絵文字を含む場合は、入力欄に残ったのは見た目では6文字(length は9)で、2つ目の「👍🏽」(length は4)は入りませんでした。家族の絵文字「👨👩👧」は、入りきる長さだけが残り、男性の絵文字「👨」1つに変わりました。
maxLength は UTF-16 のコード単位で数えるため、絵文字が多い入力では、見た目の文字数よりずっと早く止まります。絵文字の途中で切れて、入力した人が意図しない別の絵文字に変わることもあります。見た目の文字数で上限を決めたいときは、この記事の部品のように、入力は止めずに数えて知らせるほうが確実です。
maxLength で止まるのは、入力欄で文字を入力したときだけです。ボタンを押して、15文字の文字列を state から value に入れると、15文字がそのまま表示されました。このとき validity.tooLong は false、checkValidity() は true で、送信を止める理由にもなりません。MDN の tooLong の説明にも、tooLong は利用者が編集したあとの値について判断すると書かれています。
日本語の変換中の文字も、maxLength では止まりませんでした。7文字のあとに「くけこさしす」を確定せずに入力すると、入力欄には13文字が入りました。「区家古差しす」で確定すると、「あいうえおかき区家古」の10文字に切られ、「差しす」は消えました。
変換中の文字も、文字数に入る
この記事の部品では、変換中(確定前)の文字も onChange で state に入るので、文字数に数えられます。11文字のあとに、変換中の文字を入れたり確定したりした結果です。
| 操作 | 文字数の表示 |
|---|---|
| 11文字を入れる | あと9文字(11/20) |
| 「いそがしいひです」を変換中 | あと1文字(19/20) |
| 「いそがしいひですね。」を変換中 | 1文字オーバー(21/20) |
| 「忙しい日ですね。」で確定 | あと1文字(19/20) |
ひらがなのまま変換中のときは、確定したあとより文字数が多くなることがあります。上の例では、変換中に「1文字オーバー」と出ましたが、漢字に確定すると19文字に戻り、form.checkValidity() も true になりました。maxLength のように確定したときに文字が切られることはないので、入力した人は、表示を見ながら自分で文を短くできます。





