Reactで、正規表現を試せる入力欄を作る方法
正規表現の入力欄と試す文字列を用意しておけば、入力するたびに一致した部分とキャプチャグループの中身を表示できます。作るときに気をつけるのは、書きかけの正規表現で部品が消えないこと、長さ 0 の一致で処理が無限に続かないこと、時間のかかるパターンでページが固まらないことの3つです。
ここでは、ライブラリを使わずに RegexTester という部品を作りました。正規表現の実行は runRegex という関数にまとめ、String.prototype.matchAll() で一致をすべて集めます。後半では、同じ関数を Web Worker で動かし、1秒たっても終わらなければ止める形に置き換えました。
検証環境は Ubuntu 24.04、Node.js 22.23.4、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 を esbuild で変換し、tsc で型エラーがないことを確かめたうえで、StrictMode で包んだ開発用のビルドを Playwright で操作しました。Firefox と Safari、スマートフォンでの入力、日本語入力(IME)の変換中の動き、スクリーンリーダーでの読み上げ、d フラグ(一致した位置を返す)は試していません。

GIF のカーソル、クリックの輪、下の帯の表示は、撮影のために重ねたものです。件数が変わった瞬間だけ、その表示をピンクにしています。撮影のときは、フォントと文字の大きさを記事の CSS から変えています。
正規表現の実行を runRegex にまとめる
画面の部品から切り離して、正規表現を作って実行する関数を先に書きました。後で Worker から呼ぶときも、同じ関数を使います。
export type MatchInfo = {
index: number; // 一致した位置(UTF-16 の単位)
end: number;
text: string;
captures: (string | undefined)[]; // $1, $2, … の中身
named: Record<string, string | undefined>; // (?<名前>…) の中身
};
export type RunResult =
| { ok: true; matches: MatchInfo[]; truncated: boolean }
| { ok: false; error: string };
export const MAX_MATCHES = 500;
export function runRegex(pattern: string, flags: string, text: string): RunResult {
let regExp: RegExp;
try {
// 一覧を作るために g は必ず付ける。g がオフなら、最初の1件だけを返す
regExp = new RegExp(pattern, flags.includes('g') ? flags : flags + 'g');
} catch (e) {
return { ok: false, error: e instanceof SyntaxError ? e.message : String(e) };
}
const limit = flags.includes('g') ? MAX_MATCHES : 1;
const matches: MatchInfo[] = [];
for (const m of text.matchAll(regExp)) {
if (matches.length === limit) return { ok: true, matches, truncated: limit === MAX_MATCHES };
matches.push({
index: m.index,
end: m.index + m[0].length,
text: m[0],
captures: m.slice(1),
named: { ...m.groups },
});
}
return { ok: true, matches, truncated: false };
}
matchAll() に正規表現を渡すときは、g フラグが必要です。MDN の matchAll() の説明にあるとおり、g がないと TypeError になります(以前の記事「Reactで、テキストエリアの文章を検索して置換する方法」でも確かめました)。そこで、利用者が g を外したときも内部では g を付けて実行し、最初の1件だけを返しています。MDN の match() の説明にあるとおり、match() も g がなければ最初の一致だけを返すので、それと同じ見え方になります。
一致の数には上限を設けました。. のように1文字ずつ一致するパターンを長い文章に当てると、一覧の行がその数だけ並ぶからです。検証環境で . を800文字の文字列に当てたところ、「500件一致(ここで打ち切りました)」と出て、強調は500か所、一覧は500行で止まりました。
m.slice(1) は $1 以降のキャプチャグループの中身で、m.groups は名前付きグループの中身です。MDN のキャプチャグループの説明にあるとおり、(ab)|(cd) の選ばれなかったほうのように、一致に使われなかったグループには undefined が入ります。表示では、空文字列 "" と区別するために「(なし)」と書いています。
入力欄とフラグのチェックボックス
画面の部品は次のとおりです。正規表現の文字列、フラグの文字列、試す文字列の3つを state に持ち、useMemo で runRegex を呼びます。
import { useMemo, useState, type ReactNode } from 'react';
import { runRegex, type MatchInfo, type RunResult } from './runRegex';
const FLAGS = ['g', 'i', 'm', 's', 'u', 'v', 'y'] as const;
export function RegexTester() {
const [pattern, setPattern] = useState('(?<year>\\d{4})-(\\d{2})-(\\d{2})');
const [flags, setFlags] = useState('g');
const [text, setText] = useState('2026-11-05 猫の健康診断\n2027-02-22 猫の日');
const result: RunResult | null = useMemo(() => runRegex(pattern, flags, text), [pattern, flags, text]);
const toggleFlag = (flag: string) =>
setFlags((prev) =>
FLAGS.filter((f) => (f === flag ? !prev.includes(f) : prev.includes(f))).join(''),
);
return (
<div className="rx">
<label className="rx-pattern">
<span>/</span>
<input
aria-label="正規表現"
value={pattern}
onChange={(e) => setPattern(e.target.value)}
spellCheck={false}
autoCapitalize="off"
autoComplete="off"
/>
<span>/{flags}</span>
</label>
<fieldset className="rx-flags">
<legend>フラグ</legend>
{FLAGS.map((f) => (
<label key={f}>
<input type="checkbox" checked={flags.includes(f)} onChange={() => toggleFlag(f)} />
{f}
</label>
))}
</fieldset>
<textarea aria-label="試す文字列" value={text} onChange={(e) => setText(e.target.value)} rows={4} />
{result === null ? (
<p className="rx-status">調べています…</p>
) : !result.ok ? (
<p role="alert" className="rx-error">{result.error}</p>
) : (
<>
<p className="rx-status" aria-live="polite">
{result.matches.length}件一致{result.truncated && '(ここで打ち切りました)'}
</p>
<Highlighted text={text} matches={result.matches} />
<MatchList matches={result.matches} />
</>
)}
</div>
);
}
function Highlighted({ text, matches }: { text: string; matches: MatchInfo[] }) {
const parts: ReactNode[] = [];
let pos = 0;
matches.forEach((m, i) => {
if (m.index > pos) parts.push(text.slice(pos, m.index));
// 長さ 0 の一致(^ や \b など)は、文字がないので縦線で位置だけを示す
parts.push(
m.end === m.index ? <mark key={i} className="rx-empty" /> : <mark key={i}>{m.text}</mark>,
);
pos = m.end;
});
parts.push(text.slice(pos));
return <pre className="rx-preview">{parts}</pre>;
}
function MatchList({ matches }: { matches: MatchInfo[] }) {
return (
<ol className="rx-list">
{matches.map((m, i) => (
<li key={i}>
<code>{JSON.stringify(m.text)}</code>({m.index}〜{m.end})
{m.captures.map((c, j) => (
<div key={j} className="rx-group">${j + 1}: {c === undefined ? '(なし)' : JSON.stringify(c)}</div>
))}
{Object.entries(m.named).map(([name, c]) => (
<div key={name} className="rx-group">{name}: {c === undefined ? '(なし)' : JSON.stringify(c)}</div>
))}
</li>
))}
</ol>
);
}
正規表現の欄は、new RegExp() に渡す文字列をそのまま入力する欄です。/…/ で囲む書き方ではないので、\d は \d と打てば済みます。/ もそのまま打てます。検証環境では、a/b で x a/b y の中の a/b に一致しました。前後の / とフラグは、見た目をそろえるために横に添えているだけです。入力欄には、スペルチェックを止める spellCheck={false}(spellcheck 属性の説明)と、自動の大文字化を止める autoCapitalize="off"(autocapitalize 属性の説明)を付けました。正規表現は英単語ではないので、綴りの確認や、スマートフォンの仮想キーボードなどで先頭の文字を大文字にする機能は邪魔になります。
フラグは文字列で持ち、チェックボックスを押すたびに FLAGS の並びで作り直します。押した順に足していくと ig と gi のように表示が変わってしまうので、いつも同じ順になるようにしました。
最初の値では、2つの日付が一致し、一覧には次のように出ました。$1 と year は同じグループです。名前付きのグループにも、ほかのグループと一緒に番号が振られるので、両方に出ます。
1. "2026-11-05"(0〜10) / $1: "2026" / $2: "11" / $3: "05" / year: "2026"
2. "2027-02-22"(18〜28) / $1: "2027" / $2: "02" / $3: "22" / year: "2027"
一致した部分の強調は、文字列を一致の前後で切り分け、一致した部分だけを <mark> で囲んで並べています。HTML の文字列を組み立てて dangerouslySetInnerHTML に渡すことはしていません。CSS は次のとおりです。
.rx { display: grid; gap: 10px; max-width: 520px; font-size: 15px; color: #1f2a30; }
.rx-pattern { display: flex; align-items: center; gap: 4px; font-family: ui-monospace, monospace; }
.rx-pattern input { flex: 1; font: inherit; padding: 6px 8px; }
.rx-flags { display: flex; flex-wrap: wrap; gap: 12px; border: 1px solid #c8dde3; font-family: ui-monospace, monospace; }
.rx textarea { font: 14px/1.6 ui-monospace, monospace; padding: 6px 8px; }
.rx-preview { margin: 0; padding: 8px; background: #eefafd; font: 14px/1.6 ui-monospace, monospace; white-space: pre-wrap; overflow-wrap: anywhere; }
.rx-preview mark { background: #b8f0fa; color: inherit; border-radius: 2px; }
.rx-preview mark.rx-empty { display: inline-block; width: 0; height: 1.2em; vertical-align: text-bottom; border-left: 2px solid #d85878; background: none; }
.rx-error { color: #b03050; font-family: ui-monospace, monospace; }
.rx-list { margin: 0; padding-left: 1.6em; font-family: ui-monospace, monospace; font-size: 14px; }
.rx-group { padding-left: 1em; color: #067a98; }
フラグを切り替えたときの一致の違い
各フラグの意味は、MDN の正規表現ガイドの「フラグを用いた高度な検索」の表にまとまっています。この部品で切り替えて、一致がどう変わるかを確かめました。
| パターンと試す文字列 | フラグ | 一致 |
|---|---|---|
neko と Neko neko NEKO |
g |
neko の1件 |
| 同上 | gi |
Neko、neko、NEKO の3件 |
^\d+ と 12 ねこ(改行)34 いぬ |
g |
12 の1件 |
| 同上 | gm |
12 と 34 の2件 |
a.b と a(改行)b |
g |
0件 |
| 同上 | gs |
改行をはさんだ a\nb の1件 |
\p{L}+ と p{L} 猫cat |
gu |
p、L、猫cat の3件 |
| 同上 | g |
p{L} という文字そのものの1件 |
最後の2行は、間違えても気づきにくい例です。\p{L} は「文字」を表す書き方ですが、MDN の unicode フラグの説明にあるとおり、u がないと \p は単なる p として扱われます。エラーにならず、思わぬ文字列に一致するだけなので、間違いに気づきにくくなります。
書きかけの正規表現は、エラーとして表示する
入力欄では、( を打った直後のように、正規表現として正しくない状態が必ず通ります。MDN の RegExp() コンストラクターの説明にあるとおり、new RegExp() は、正規表現として読めないパターンや、使えないフラグを受け取ると SyntaxError を投げます。runRegex はこれを try / catch で受けて、メッセージを返します。
try / catch がないと、描画中の例外で部品ごと画面から消えてしまいます。検索欄の入力をそのまま正規表現にしたときに起きることは、以前の記事「Reactで、検索した言葉を結果の中で強調して表示する方法」で確かめました。
検証環境の Chromium では、次のメッセージが出ました。エラーのあいだは、一致の強調と一覧を表示しません。冒頭の GIF のように、( を消すと元の表示に戻ります。
| 入力 | 表示されたメッセージ |
|---|---|
( |
Invalid regular expression: /(/g: Unterminated group |
[a- |
Invalid regular expression: /[a-/g: Unterminated character class |
a{2,1} |
Invalid regular expression: /a{2,1}/g: numbers out of order in {} quantifier |
(?<n>a)(?<n>b) |
Invalid regular expression: /(?a)(?b)/g: Duplicate capture group name |
\(最後が \) |
Invalid regular expression: //g: \ at end of pattern |
a に u と v を両方付けた |
Invalid flags supplied to RegExp constructor 'guv' |
u と v は、MDN の unicodeSets の説明にあるとおり、同じパターンを違う規則で読むので、両方を付けると SyntaxError になります。パターンが正しくても、チェックボックスの組み合わせでエラーになります。そのため、エラーはパターンの欄の横ではなく、一致の結果を出す場所に表示しています。
長さ 0 の一致は、matchAll に進めてもらう
a*、\b、^ のように、文字を1つも含まない一致(長さ 0 の一致)を返すパターンがあります。この部品では、そうした一致を縦線で示しています。検証環境で a* を baac に当てたところ、4件になりました。
1. ""(0〜0)
2. "aa"(1〜3)
3. ""(3〜3)
4. ""(4〜4)
MDN の matchAll() の説明にあるとおり、matchAll() がなかったころは、exec() を while で回して一致を集めていました。ただ、長さ 0 の一致では lastIndex が進まないので、同じ位置で一致し続けます。Node.js で、8回で止める条件を入れて試した結果です。
exec の while: 0:"" 0:"" 0:"" 0:"" 0:"" 0:"" 0:"" 0:"" …(8回で打ち切り) lastIndex = 0
matchAll : 0:"" 1:"aa" 3:"" 4:""
exec() のほうは、この条件がなければ終わりません。MDN の exec() の説明でも、長さ 0 の一致がありうるときは、自分で lastIndex を進めるよう注意しています。入力欄には利用者がどんなパターンを書くか分からないので、自分で進めずに済む matchAll() を使いました。MDN の RegExp の Symbol.matchAll の説明にあるとおり、matchAll() は一致が空文字列のとき、lastIndex を1つ先へ進めます。
その「1つ」の数え方は、u フラグで変わります。空のパターンを 🐈a に当てると、u なしでは位置 0、1、2、3 の4件、u ありでは 0、2、3 の3件でした。🐈 は UTF-16 で2つ分の長さがあり、u なしでは、その2つのあいだにも一致します。一覧に出す位置も UTF-16 の単位なので、絵文字のうしろでは、見た目の文字数より大きな数になります。
時間のかかるパターンを入力すると、ページが固まる
正規表現には、文字列によって実行時間が急に長くなるパターンがあります。OWASP の ReDoS の説明に例として出ている ^(a+)+$ に、a を n 個並べて最後に ! を付けた文字列を渡すと、Node.js では次のように時間が延びました。最初の1回には準備の時間が混ざるので、測る前に1回実行しておき、そのあとの時間を測っています。
| n | 時間 |
|---|---|
| 20 | 11ms |
| 22 | 26ms |
| 24 | 105ms |
| 26 | 419ms |
| 28 | 1755ms |
試す文字列に a を28個と ! を入れておき、パターンの欄を ^(a+)+$ にしたところ、検証環境の Chromium では、ページが次に応答するまで 4228ms かかりました。そのあいだ、ページはほかの操作に応えません。本番用にビルドしたものでは 2158ms でした。useMemo のリファレンスにも、開発環境での測定は正確ではなく、Strict Mode では計算の関数が2回呼ばれる、と書かれています。
入力のたびに実行する部品では、利用者が書いたパターンと文字列の組み合わせしだいで、ページがこのように止まります。
Worker で実行して、1秒で止める
ページを固めないために、runRegex を Web Worker の中で動かしました。MDN の Web Worker の使い方にあるとおり、Worker のスクリプトは画面とは別のスレッドで動きます。Worker 側は、受け取った値で runRegex を呼んで、結果を返すだけです。
/// <reference lib="webworker" />
import { runRegex } from './runRegex';
self.onmessage = (e: MessageEvent<{ pattern: string; flags: string; text: string }>) => {
const { pattern, flags, text } = e.data;
self.postMessage(runRegex(pattern, flags, text));
};
これを esbuild で regex-worker.js として別にビルドし、ページと同じ場所に置きました。部品からは、次のフックで呼びます。
import { useEffect, useState } from 'react';
import type { RunResult } from './runRegex';
const TIMEOUT_MS = 1000;
// 正規表現の実行を Worker に任せ、時間がかかりすぎたら Worker ごと止める
// 次の結果が届くまでは、前の結果をそのまま返す(最初だけ null)
export function useRegexWorker(pattern: string, flags: string, text: string): RunResult | null {
const [result, setResult] = useState<RunResult | null>(null);
useEffect(() => {
const worker = new Worker('regex-worker.js');
const timer = setTimeout(() => {
worker.terminate();
setResult({ ok: false, error: `${TIMEOUT_MS / 1000}秒たっても終わらないので、止めました` });
}, TIMEOUT_MS);
worker.onmessage = (e: MessageEvent<RunResult>) => {
clearTimeout(timer);
worker.terminate();
setResult(e.data);
};
worker.postMessage({ pattern, flags, text });
// 入力が変わったら、前の実行は結果を待たずに止める
return () => {
clearTimeout(timer);
worker.terminate();
};
}, [pattern, flags, text]);
return result;
}
前の節で確かめたとおり、同じスレッドで正規表現を実行しているあいだは、ほかの処理が動けません。Worker なら画面のスレッドは空いているので、タイマーで時間を計り、実行中の Worker ごと terminate() で止めます。MDN の terminate() の説明にあるとおり、Worker は処理の途中でもすぐに止まります。このフックでは、入力が変わるたびに新しい Worker を作ります。
部品の側で変えるのは、result を作る1行と import だけです。
- const result: RunResult | null = useMemo(() => runRegex(pattern, flags, text), [pattern, flags, text]);
+ const result: RunResult | null = useRegexWorker(pattern, flags, text);
result が null になるのは、最初の結果が届く前だけで、部品はそのあいだ「調べています…」と書くようにしてあります。2回目以降は前の結果を残したまま、次の結果を待ちます。
前の節と同じ条件で ^(a+)+$ を入力したところ、ページは 21ms で応答しました。入力した直後も前の結果(a 1文字ずつの28件)が表示されたままで、約1.3秒後(1320ms)に「1秒たっても終わらないので、止めました」に変わりました。続けてパターンを ^(a+)+!$ に直すと、40ms で1件の一致が表示されました。
時間のかかるパターンを入力して 300ms 後に、すぐ終わるパターンへ書き換える操作も試しました。最初に入力してから約1.5秒たっても、表示は書き換えたあとのパターンの結果のままで、「止めました」のエラーは出ませんでした。入力が変わると、useEffect の片付けの関数で、前の Worker を止めてタイマーも消しているためです。
Worker は最後にまとめて結果を返すので、止めたときは途中までの一致を受け取れず、表示は「止めました」だけになります。長い文章で試すときに1秒では足りなければ、TIMEOUT_MS を延ばします。





