Reactで、テキストエリアの文章を検索して置換する方法
テキストエリアの文章を検索して置換するには、まず一致した位置をすべて「始まりと終わりの番号」の組で求めておきます。「次を検索」ではその範囲を setSelectionRange で選び、「置換」では選んでいる1件だけを文字列の中で差し替えます。「すべて置換」は replace に関数を渡して行います。
検索欄、置換後の欄、3つのボタンと本文のテキストエリアを持つ部品を作り、書き方を変えたときに何が起きるかも確かめました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、Chromium 153 です。使ったのは React 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 を操作しました。Firefox と Safari、スマートフォンのタッチ操作、日本語入力(IME)の変換中の操作、スクリーンリーダーでの読み上げ、長い文章での速さ、正規表現そのものを検索語として受け付ける機能、document.execCommand() を使って置換する書き方は試していません。

一致した位置を、すべて求めておく
検索の処理は、React に関係のない関数にまとめました。findMatches は、一致した部分の始まりと終わりの位置を { start, end } の配列で返します。end は、一致した部分の次の文字の位置です。この組は、あとで setSelectionRange にそのまま渡せます。
export type Match = { start: number; end: number };
// 正規表現で特別な意味を持つ文字の前に \ を付ける
export function escapeRegExp(text: string) {
return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
// 検索語を、そのままの文字として探す正規表現にする(g フラグは必ず付ける)
export function toSearchRegExp(query: string, matchCase: boolean): RegExp | null {
if (query === '') return null;
return new RegExp(escapeRegExp(query), matchCase ? 'g' : 'gi');
}
// 一致した位置を、すべて [start, end) の組で返す
export function findMatches(text: string, regExp: RegExp | null): Match[] {
if (!regExp) return [];
return [...text.matchAll(regExp)].map((m) => ({ start: m.index, end: m.index + m[0].length }));
}
// すべて置換する。置換後の文字を関数で返すと、$& などが特別扱いされない
export function replaceAllMatches(text: string, regExp: RegExp, replacement: string) {
return text.replace(regExp, () => replacement);
}
検索語は、正規表現に変える前に escapeRegExp で記号の前に \ を付けて、ただの文字として扱われるようにしています。こうすると「1.5」で検索したとき、「105」には一致せず、「1.5」にだけ一致します。記号をそのまま正規表現にしたときに起きることは、以前の記事「Reactで、検索した言葉を結果の中で強調して表示する方法」で確かめました。
大文字と小文字を区別しないときは、i フラグを付けます。どちらの場合も g フラグは必ず付けます。MDN の matchAll() の説明にあるとおり、g フラグのない正規表現を渡すと TypeError になるためです。Node.js で確かめると、次のエラーが出ました。
matchAll: TypeError String.prototype.matchAll called with a non-global RegExp argument
「猫は朝に鳴く。子猫は夜に鳴く。Cat も cat も同じ。」で試した結果は、次のとおりです。
「猫」 [{"start":0,"end":1},{"start":8,"end":9}]
「cat」区別しない [{"start":15,"end":18},{"start":21,"end":24}]
「cat」区別する [{"start":21,"end":24}]
「」(空) []
検索語が空のときは、正規表現を作らずに null を返し、一致なしとして扱っています。検索語を空の文字列にして replaceAll を呼ぶと、MDN の replaceAll() の説明にあるとおり、1文字ごとのあいだに置換後の文字が入ります。'猫と犬'.replaceAll('', '★') は、先頭と末尾も含めて ★猫★と★犬★ になりました。
部品の全体
画面の部品は次のとおりです。本文は state の text に持ち、一致の一覧 matches は、本文か検索の条件が変わったときだけ計算し直します。今どの一致を選んでいるかは、本文の選択範囲 selection から求めます。
import { useEffect, useMemo, useRef, useState } from 'react';
import { flushSync } from 'react-dom';
import { findMatches, replaceAllMatches, toSearchRegExp, type Match } from './textSearch';
const INITIAL = '猫は朝に鳴く。\n子猫は夜に鳴く。\nネコもねこも猫も、Cat も cat も同じ。';
export function FindReplace() {
const [text, setText] = useState(INITIAL);
const [query, setQuery] = useState('');
const [replacement, setReplacement] = useState('');
const [matchCase, setMatchCase] = useState(false);
const [selection, setSelection] = useState<Match>({ start: 0, end: 0 });
const [message, setMessage] = useState('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const regExp = useMemo(() => toSearchRegExp(query, matchCase), [query, matchCase]);
const matches = useMemo(() => findMatches(text, regExp), [text, regExp]);
// 今選んでいる範囲が、何件目の一致か(一致を選んでいなければ -1)
const current = matches.findIndex((m) => m.start === selection.start && m.end === selection.end);
// 本文の選択範囲が変わるたびに覚えておく(クリック、キー操作、setSelectionRange のどれでも起きる)
useEffect(() => {
const textarea = textareaRef.current!;
const update = () => setSelection({ start: textarea.selectionStart, end: textarea.selectionEnd });
textarea.addEventListener('selectionchange', update);
return () => textarea.removeEventListener('selectionchange', update);
}, []);
function select(m: Match) {
const textarea = textareaRef.current!;
textarea.focus();
textarea.setSelectionRange(m.start, m.end);
}
// from の位置より後ろで最初の一致を選ぶ。なければ先頭に戻る
function selectNext(list: Match[], from: number) {
if (list.length === 0) return;
select(list.find((m) => m.start >= from) ?? list[0]);
}
function handleFindNext() {
setMessage('');
selectNext(matches, textareaRef.current!.selectionEnd);
}
function handleReplace() {
setMessage('');
const m = matches[current];
// 一致を選んでいないときは、置換せずに次の一致を選ぶだけにする
if (!m) return selectNext(matches, textareaRef.current!.selectionEnd);
const next = text.slice(0, m.start) + replacement + text.slice(m.end);
// 新しい value を DOM に入れ終えてから、選択範囲を動かす
flushSync(() => setText(next));
// 置き換えた文字の後ろから探す(置換後の文字の中で、また一致しないように)
selectNext(findMatches(next, regExp), m.start + replacement.length);
}
function handleReplaceAll() {
if (!regExp || matches.length === 0) return;
setText(replaceAllMatches(text, regExp, replacement));
setMessage(`${matches.length}件を置換しました`);
}
return (
<div className="find-replace">
<div className="row">
<input aria-label="検索" placeholder="検索" value={query} onChange={(e) => setQuery(e.target.value)} />
<label>
<input type="checkbox" checked={matchCase} onChange={(e) => setMatchCase(e.target.checked)} />
大文字と小文字を区別
</label>
</div>
<div className="row">
<input aria-label="置換後" placeholder="置換後" value={replacement} onChange={(e) => setReplacement(e.target.value)} />
</div>
<div className="row">
<button onClick={handleFindNext} disabled={matches.length === 0}>次を検索</button>
<button onClick={handleReplace} disabled={matches.length === 0}>置換</button>
<button onClick={handleReplaceAll} disabled={matches.length === 0}>すべて置換</button>
<span className="status" role="status">
{message || (query === '' ? '' : `${matches.length}件中 ${current + 1 > 0 ? current + 1 : '-'}件目`)}
</span>
</div>
<textarea
ref={textareaRef}
aria-label="本文"
rows={5}
value={text}
onChange={(e) => setText(e.target.value)}
/>
</div>
);
}
ここからは、ボタンごとの処理と、書き方を変えたときに起きたことを順に見ていきます。
次を検索は、setSelectionRange で一致を選ぶ
「次を検索」では、今のキャレットか選択範囲の終わり(selectionEnd)より後ろで、最初の一致を選びます。後ろになければ先頭の一致に戻ります。選ぶときは、テキストエリアに focus() してから、setSelectionRange() に一致の始まりと終わりを渡します。
検索欄に「猫」と入れてから「次を検索」を4回押すと、選択範囲と件数の表示は次のように変わりました。4回目で1件目に戻っています。
検索欄に「猫」 選択 0〜0「」 | 表示「3件中 -件目」 | フォーカス: 検索
次を検索 1回目 選択 0〜1「猫」 | 表示「3件中 1件目」 | フォーカス: 本文
次を検索 2回目 選択 9〜10「猫」 | 表示「3件中 2件目」 | フォーカス: 本文
次を検索 3回目 選択 23〜24「猫」 | 表示「3件中 3件目」 | フォーカス: 本文
次を検索 4回目 選択 0〜1「猫」 | 表示「3件中 1件目」 | フォーカス: 本文
同じ本文で「cat」を検索すると2件、「大文字と小文字を区別」にチェックを入れると1件になりました。
何件目かは、テキストエリアの selectionchange で追う
「3件中 2件目」の表示には、今の選択範囲が要ります。選択範囲は、ボタンで選んだときだけでなく、本文をクリックしたり、矢印キーで動かしたりしても変わります。そこで、テキストエリアの selectionchange イベントで、そのつど selection を書き換えています。公式リファレンスの、DOM 要素に共通の props の一覧には onSelect はありますが、onSelectionChange はありません。そのため、useEffect の中で addEventListener しています。
比べるために、React の onSelect で選択範囲を覚える版も作りました。この版では、select の中でも setSelection(m) を呼びます。この版で「次を検索」を押して1件目を選んだあと、本文の左上をクリックしてキャレットを先頭に移しても、表示は「3件中 1件目」のままでした。
本文の左上をクリック(キャレットを先頭へ)
selectionchange で追う版 選択 0〜0「」 | 表示「3件中 -件目」
onSelect で追う版 選択 0〜0「」 | 表示「3件中 1件目」
onSelect で追う版でも、そのあと End キーで1行目の末尾に移って「猫」と打つと、表示は「4件中 -件目」に変わりました。クリックのときに表示が変わらなかった理由までは調べていません。
MDN にある、テキストエリアの selectionchange イベントの説明では、このイベントは選択範囲が変わったときに、キャレットだけの場合も含めて起きるとされています。MDN のブラウザー対応表のデータでは、テキストエリアでこのイベントが起きるのは Chrome 127、Firefox 92、Safari 18 からです。それより前の Chrome と Safari では、document で起きていたと注記されています。
検証のスクリプトでは、操作のあと 50 ミリ秒待ってから表示を読んでいます。
1件の置換では、flushSync で value を入れてから選ぶ
「置換」では、今選んでいる一致を text.slice で切り取って差し替え、そのあとで次の一致を選びます。このとき、新しい文字列を state に入れるのを flushSync で包んでいます。
flushSync を使わずに setText(next) とだけ書いた版では、選択範囲が本文の最後に飛びました。公式リファレンスの useState の注意点にあるとおり、set 関数は次の描画の state を更新するだけなので、setText の直後には、テキストエリアの中身はまだ古いままです。その状態で次の一致を選んでも、そのあと React が新しい value を入れたときに、選択範囲が本文の最後に移ったと考えられます。
「猫」を「子猫」に1件ずつ置換(flushSync を使わない版)
置換 1回目 本文は40文字 | 選択 0〜1「猫」 | 表示「3件中 1件目」
置換 2回目 本文は41文字 | 選択 41〜41「」 | 表示「3件中 -件目」
置換 3回目 本文は41文字 | 選択 1〜2「猫」 | 表示「3件中 1件目」
1回目の「置換」は、一致を選んでいないので、1件目を選ぶだけです。2回目で1件目が「子猫」に変わりますが、選択範囲は位置41、つまり本文の最後に移りました。3回目は一致を選んでいないので、本文の最後から次を探し、先頭に戻って、今置き換えた「子猫」の中の「猫」を選んでいます。
公式リファレンスの flushSync のページによると、flushSync は中の更新をすぐに DOM へ反映させる関数です。同じページで、めったに使うものではなく、アプリの性能を落とすことがあるので最後の手段にするように、と書かれています。ここでは、ボタンを押したときに1回だけ使っています。
次の一致は、置き換えた文字の後ろから探す
置換したあとの次の一致は、置き換えた位置に置換後の文字の長さを足した位置(m.start + replacement.length)から探しています。置換後の文字に検索語が含まれていても、その中で一致しないようにするためです。
試しに、置き換えた位置の先頭(m.start)から探す版で、「猫」を「子猫」に置換してみました。
置き換えた位置の先頭から探す版
置換 2回目 本文「子猫は朝に鳴く。…」 選択 1〜2「猫」
置換 3回目 本文「子子猫は朝に鳴く。…」 選択 2〜3「猫」
置換 4回目 本文「子子子猫は朝に鳴く。…」 選択 3〜4「猫」
置換後の「子猫」の中の「猫」がまた選ばれ、何度押しても同じ場所で「子」が増えるだけでした。この記事の版では、2回目で2件目、3回目で3件目の「猫」に進みました。4回目は、後ろに一致が残っていないので先頭に戻り、1件目の「子猫」の中の「猫」を選びました。置換後の文字に検索語が含まれるときは、一周すると、置き換えた語の中の一致も選ばれます。
すべて置換は、置換後の文字を関数で返す
「すべて置換」では、replace の2つ目の引数に、置換後の文字を返す関数(() => replacement)を渡しています。文字列をそのまま渡すと、置換後の欄に入れた $ が特別な意味を持ってしまうためです。
MDN の replace() の説明にあるとおり、置換後の文字列の中の $& は「一致した部分」、$$ は「$ 1文字」に置き換わります。関数が返した文字列には、この置き換えは行われません。「猫と子猫」の「猫」を「$&$&」に置換して比べました。
replace(正規表現, 文字列) 猫猫と子猫猫
replaceAll(文字列, 文字列) 猫猫と子猫猫
replace(正規表現, 関数) $&$&と子$&$&
検索語を正規表現にせず、replaceAll に文字列のまま渡しても、置換後の $& は置き換わりました。置換後の欄にはどんな文字が入るか分からないので、関数で返すのが確実です。画面の部品で「猫」を「$&$&」にすべて置換したときも、本文には「$&$&」がそのまま入りました。
なお、1件ずつの置換は slice でつないでいるので、$ を気にする必要はありません。
置換したあとは、Ctrl+Z で元に戻せない
この部品の置換は、state の文字列を書き換えているだけなので、ブラウザの「元に戻す」の履歴には残りません。本文で打った文字も戻せるかを見るために、まず本文の末尾に「鳴く」と打ち、検索欄に「猫」、置換後の欄に「犬」と打ってから「すべて置換」を押し、本文で Ctrl+Z を押してみました。
本文にフォーカスして Ctrl+Z(1回目)
本文「犬は朝に鳴く。⏎子犬は…同じ。鳴く」 | 置換後の欄「」 | フォーカス: 置換後
Ctrl+Z(2回目)
本文「犬は朝に鳴く。⏎子犬は…同じ。鳴く」 | 置換後の欄「」 | フォーカス: 検索
本文は「犬」のまま変わらず、本文の末尾に打った「鳴く」も残りました。検証環境の Chromium では、1回目の Ctrl+Z で置換後の欄に打った「犬」が消え、フォーカスもその欄に移りました。2回目では、フォーカスが検索欄に移っています。
置換を Ctrl+Z で戻せるようにしたいときは、document.execCommand('insertText') でブラウザの編集として文字を入れる方法があります。MDN の execCommand() の説明では、非推奨のメソッドだが、DOM を直接書き換える方法と違って編集の履歴が残る、とされています。この方法と、state を書き換える方法で Ctrl+Z の効き方がどう違うかは、以前の記事「Reactで、絵文字を選んで入力欄に入れるピッカーを作る方法」で、絵文字を入れる場合について比べました。





