リリースから数日後、レビュー欄に「変換確定できないバグあり」という星1の一行を見つけて、胃が痛くなりました。Rorkで作ったメモアプリです。手元の英語キーボードでは何事もなく動いていたので、最初は原因の見当すらつきませんでした。
「日本語を入力すると挙動がおかしい」「変換中に文字が消える」「検索ボックスがガタガタする」——日本のユーザーから届くこの種の声は、たいてい同じ根から来ています。生成されたコードが、日本語IME(変換)の存在を前提にしていないのです。
英語圏の開発者がテストしただけでは見つかりにくい落とし穴なので、Rorkの生成コードでよく見かけるパターンを題材に、React NativeのTextInputと日本語IMEの相性を整理します。手元のコードをその日のうちに直せるよう、手順は短く区切ってお伝えします。
なぜ変換中にイベントが暴発するのか
React NativeのTextInputのonChangeTextは、英語のようなアルファベット入力を前提に設計されています。1文字打つたびに確定文字として扱われ、即座に親コンポーネントへ通知が飛びます。
ところが日本語IMEでは、とくにiOSの日本語キーボードで、変換確定前の「未確定文字列」(ひらがなで表示されて下線が引かれている状態)も、OSからは入力イベントとして飛んできます。つまり「こんにちは」と打って変換候補を選ぶまでの間に、onChangeTextが5〜10回以上発火してしまうのです。
このイベント暴発が、次のような現象を引き起こします。
- 検索ボックスでAPIリクエストが1文字ごとに飛び、変換候補が出るたびにネットワーク負荷とレート制限エラーが起きます
- Zustandなどで状態更新すると、その再描画が変換候補ウィンドウを強制的に閉じてしまいます
- キーボードや端末によっては、変換を確定したつもりのEnterで
onSubmitEditingが発火し、意図しない画面遷移が起きます
一方でWebのHTMLでは、compositionstart・compositionupdate・compositionendというイベントで変換中の状態を明示的に追跡できます。React Nativeにはこれに対応する仕組みが用意されていないため、自前で工夫する必要があります。
症状を再現する最小コード
まずは問題がどのように現れるかを確認できる、最小の再現コードを紹介します。日本語環境のシミュレータまたは実機で試してみてください。
import { useState } from "react";
import { TextInput, View, Text } from "react-native";
export default function BrokenSearchBox() {
const [query, setQuery] = useState("");
const [callCount, setCallCount] = useState(0);
// ❌ 悪い例:変換中も含めて毎回呼ばれる
const handleChange = (text: string) => {
setQuery(text);
setCallCount((c) => c + 1);
// 本来はここで検索APIを呼ぶ想定
console.log("検索API呼び出し:", text);
};
return (
<View style={{ padding: 24 }}>
<TextInput
value={query}
onChangeText={handleChange}
placeholder="検索..."
style={{ borderWidth: 1, padding: 12, borderRadius: 8 }}
/>
<Text>API呼び出し回数: {callCount}</Text>
</View>
);
}このコードで「東京駅」と入力しようとすると、確定までに callCount が20回前後まで跳ね上がります。1回の検索で20回のAPIリクエストを飛ばす検索ボックスは、率直に言って本番では使えません。
解決策1:デバウンスで暴発を吸収する
最もシンプルな対策は、連続した変化を「ある程度まとまってから」処理することです。いわゆるデバウンス(debounce)と呼ばれる技法で、IMEの仕様を知らなくても症状を緩和できます。
import { useState, useRef, useEffect } from "react";
import { TextInput, View, Text } from "react-native";
export default function DebouncedSearchBox() {
const [inputValue, setInputValue] = useState("");
const [committedQuery, setCommittedQuery] = useState("");
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// 300ms変更がなければ「確定した」とみなしてAPIへ
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => {
setCommittedQuery(inputValue);
}, 300);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [inputValue]);
useEffect(() => {
if (committedQuery.length === 0) return;
// ここで実際の検索APIを呼ぶ
console.log("検索API呼び出し(確定後):", committedQuery);
}, [committedQuery]);
return (
<View style={{ padding: 24 }}>
<TextInput
value={inputValue}
onChangeText={setInputValue}
placeholder="検索..."
style={{ borderWidth: 1, padding: 12, borderRadius: 8 }}
/>
<Text>確定クエリ: {committedQuery}</Text>
</View>
);
}ポイントは、入力欄のstate(inputValue)とAPIへ投げるstate(committedQuery)を分けているところです。入力欄はIMEの変換中も含めて自由に更新させつつ、300ms静止したときだけAPIを呼ぶ設計にします。
入力欄に見えている文字列と、外へ送る文字列は、別の状態として持つ——この分け方が、このあとの対策すべての土台になります。
この方法は完璧ではありませんが、Rorkで生成されたコードをそのまま活かしたまま最小の修正で済むのが利点です。APIリクエストの過剰発火を防ぎたい検索ボックスには、まずこの形を試してみてください。
解決策2:Enterキーでのみ確定するパターン
リアルタイム検索にこだわらないのであれば、「Enterキー押下まで検索しない」という割り切った設計も有効です。むしろこちらのほうが、ユーザーが検索の意思表示をしたタイミングで動くため、UXとしては素直なことが多いです。
import { useState } from "react";
import { TextInput, View, Text } from "react-native";
export default function SubmitBasedSearchBox() {
const [inputValue, setInputValue] = useState("");
const [searchResult, setSearchResult] = useState<string[]>([]);
const handleSubmit = () => {
// このタイミングでは確実に変換確定済み
console.log("検索実行:", inputValue);
// モック検索結果
setSearchResult([`${inputValue}の結果1`, `${inputValue}の結果2`]);
};
return (
<View style={{ padding: 24 }}>
<TextInput
value={inputValue}
onChangeText={setInputValue}
onSubmitEditing={handleSubmit}
returnKeyType="search"
placeholder="検索語を入力してEnter"
style={{ borderWidth: 1, padding: 12, borderRadius: 8 }}
/>
{searchResult.map((r, i) => (
<Text key={i}>{r}</Text>
))}
</View>
);
}onSubmitEditingは、キーボードの「改行」や「検索」キーが押されたときだけ発火します。returnKeyType="search"を設定するとキーボードに虫眼鏡アイコンが表示され、ユーザーに「ここで検索が実行される」ことが伝わりやすくなります。
私はRorkで作ったアプリをApp Storeにリリースする際、この方式に切り替えたあとはIME関連のクレームがぱったり消えました。個人的には、検索ボックスはこちらのパターンをデフォルトにすることをおすすめします。
解決策3:multiline TextInputで改行と確定を分離する
メモアプリや日記アプリのように、multiline(複数行入力)のテキストエリアを使う場合は、また別の問題が発生します。日本語IMEの確定キーと改行キーが混線して、意図しないタイミングで改行が挿入されるケースです。
これを防ぐには、onKeyPressで物理キーを監視するのではなく、UIレベルで「保存ボタンを明示的に設ける」設計にしてしまうのが現実的です。
import { useState } from "react";
import { TextInput, View, Text, Pressable } from "react-native";
export default function MultilineMemoEditor() {
const [memo, setMemo] = useState("");
const [saved, setSaved] = useState(false);
const handleSave = () => {
// 保存ボタンが押されたときのみ永続化
console.log("メモを保存:", memo);
setSaved(true);
};
return (
<View style={{ padding: 24 }}>
<TextInput
value={memo}
onChangeText={(t) => {
setMemo(t);
setSaved(false);
}}
multiline
placeholder="メモを入力..."
style={{
borderWidth: 1,
padding: 12,
borderRadius: 8,
minHeight: 120,
textAlignVertical: "top",
}}
/>
<Pressable
onPress={handleSave}
style={{
marginTop: 16,
padding: 12,
backgroundColor: "#0066cc",
borderRadius: 8,
}}
>
<Text style={{ color: "white", textAlign: "center" }}>保存</Text>
</Pressable>
{saved && <Text style={{ marginTop: 8 }}>保存しました</Text>}
</View>
);
}multilineなTextInputで「Enterキーで保存」にしたい気持ちはわかるのですが、日本語の変換確定のためにもEnterキーを使う仕様上、どうしても競合します。ユーザーに明示的な保存アクションを求めるデザインのほうが、結果的にトラブルが少ないです。
変換中の value を加工しない
デバウンスでも防げない落とし穴が、もうひとつあります。onChangeText の中で入力文字列をその場で加工し、そのまま value に戻してしまう書き方です。
// ❌ 変換中の文字列まで削ったり置き換えたりすると、候補ウィンドウが閉じることがあります
<TextInput
value={value}
onChangeText={(t) => setValue(t.replace(/\s/g, "").slice(0, 20))}
/>空白の除去や文字数の切り詰めは、確定したあとにまとめて行うほうが安全です。入力中は受け取った文字列をそのまま保持し、onEndEditing か保存ボタンを押した時点で整えます。いまは「見えている文字列には手を触れず、整えるのは確定してから」という線引きで実装するようにしています。
三つの対策の使い分け
どれを選ぶかは、画面が「入力しながら結果を見る」のか「入力してから決定する」のかで決まります。
| 対策 | 向いている画面 | 気をつけたい点 |
|---|---|---|
| デバウンス(300ms前後) | 入力に合わせて候補を絞るライブ検索 | 待ち時間のぶん反応が遅れます。変換が長引くと、途中の文字で検索が走ることもあります |
| onSubmitEditingで確定 | 検索キーを押して結果を見る一般的な検索 | ライブ検索の手応えは失われます |
| 明示的な保存ボタン | メモや日記などのmultiline入力 | ボタンの位置とキーボードの重なりを実機で確かめます |
Rorkに追加で指示すべきプロンプト例
Rorkでアプリを最初から作る際、日本語ユーザー向けであることを明示的に伝えると、生成コードの質が変わってきます。私が実際に使っているプロンプトの追記例は次のようなものです。
- 「TextInputを使う画面は、日本語IMEの変換中にonChangeTextが暴発することを考慮して、debounceまたはonSubmitEditingで処理を確定させる設計にしてください」
- 「検索ボックスは、入力中は検索APIを呼ばず、
returnKeyType="search"のEnterキー押下時にのみ検索を実行してください」 - 「multilineのTextInputでは、Enterキーで保存する設計を避け、明示的な保存ボタンを設けてください」
AIにこうした前提を伝えるだけで、後から日本語特有のバグに悩まされる時間を大幅に減らせます。Rorkのプロンプト設計についてはRorkのプロンプトエンジニアリング実践ガイドでより体系的に触れていますので、あわせて読んでみてください。
日本語入力で気づきにくい3つの落とし穴
最後に、Rorkで作ったアプリを日本語環境でテストするときに、見落としがちなポイントを3つ挙げておきます。
- maxLengthが「未確定文字列」も含めて数える:
maxLength=10にしたTextInputで、変換前のひらがなが長く伸びると、確定後の漢字が入らなくなることがあります(端末やキーボードによって挙動は異なります)。制限はやや緩めに設定し、確定後に切り詰めるのが無難です。 - 絵文字の結合文字対応:家族絵文字(👨👩👧👦)のような絵文字は、複数のコードポイントをつないだ並びです。
text.lengthで数えると見た目と合わず、[...text].lengthでもコードポイント単位なので、まだ1文字にはなりません。見た目どおりに数えたいときは、Intl.Segmenterが使える環境ならそれを、使えない環境ではgrapheme単位で分割するライブラリを検討してください。 - 全角英数字と半角英数字の混在:パスワードフィールドやメールアドレス欄では、
autoCapitalize="none"とkeyboardType="email-address"を明示しておかないと、iOSで全角入力モードに入ったまま打ってしまうユーザーが発生します。
次の一歩
まずはお手元のRorkプロジェクトの中から、TextInputを使っている画面をひとつ選んで、onChangeTextに直接APIコールやstate更新の重い処理を書いていないか確認してみてください。もし書いているなら、この記事のデバウンスまたはonSubmitEditingパターンへ差し替えることで、日本語ユーザーの体感品質が目に見えて変わります。
フォーム周辺の実装をさらに磨き込みたい方は、react-hook-formとZodで作るRorkのフォームバリデーション、キーボード表示に関するUI問題はRorkアプリでキーボードが入力フィールドを隠す問題の対処ガイドも参考にしてみてください。日本語で入力する方が最初に触れるのは、機能の多さより、変換がすんなり通るかどうかなのだと思います。その手触りだけは、リリース前に必ず自分の指で確かめるようにしています。