setState したのに画面が変わらない——Rork で生成したコードを触っていて、この壁にぶつかったことのある方は多いのではないでしょうか。console.log を挟むと値は確かに変わっているのに、UI だけが古いまま止まっています。無限ループの方がまだ分かりやすくて、この「何も起こらない」バグは原因が見えにくい分、時間を溶かしがちです。
私自身、Rork で試作したアプリで半日悩んだ末に「React は参照が同じなら再描画しない」という基本ルールに戻ってようやく気づいた、という経験があります。原因は実はいくつかのパターンに集約できますので、順番に確認していけば落ち着いて切り分けられます。
よくある原因1: 配列やオブジェクトを直接ミューテートしている
一番多いのがこれです。Rork に「配列の末尾にアイテムを追加して」と頼むと、たまに .push() を使ったコードが出てくることがあります。
// ❌ これは再描画されません
const [items, setItems] = useState<string[]>([]);
const addItem = (text: string) => {
items.push(text); // 既存配列を破壊
setItems(items); // 参照は同じまま
};React は前回の state と今回の state を Object.is で比較します。items.push() は同じ配列オブジェクトを書き換えるので、参照が変わらず「変化なし」と判定されてしまうのです。
// ✅ 新しい配列を作って渡す
const addItem = (text: string) => {
setItems((prev) => [...prev, text]);
};オブジェクトも同じ原理で、user.name = 'new' ではなく setUser({ ...user, name: 'new' }) とする必要があります。Rork のプロンプトで「イミュータブルに更新して」と明示すると、この落とし穴を避けやすくなります。
よくある原因2: 古いクロージャが state を捕まえている
useEffect や setTimeout の中で state を参照すると、登録した瞬間の値が閉じ込められてしまうことがあります。
// ❌ count は常に 0 のまま
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);[] 依存なので setInterval が参照している count は初回レンダー時の値(0)で凍結され、毎秒 setCount(0 + 1) が呼ばれ続けます。結果として 1 から先に進まない、という挙動になります。
// ✅ 関数形式の setState で前の値を取り出す
useEffect(() => {
const id = setInterval(() => {
setCount((prev) => prev + 1);
}, 1000);
return () => clearInterval(id);
}, []);関数形式(setCount((prev) => ...))を使うと、そのときの最新 state が引数として渡ってくるので、クロージャに左右されません。これは Rork で useEffect が無限ループする原因と直し方 と裏表の関係にある落とし穴です。
よくある原因3: Zustand の selector が参照を比較していない
Rork で Zustand を使った状態管理コードを生成すると、こんな書き方が出てくることがあります。
// ❌ 新しいオブジェクトを毎回返すので無限再描画、もしくは更新されない
const { user, setUser } = useUserStore((s) => ({
user: s.user,
setUser: s.setUser,
}));selector が毎回新しいオブジェクトを返すため、Zustand のデフォルトの浅い等価比較では「変化あり」と見なされて再描画が走ったり、逆に別の原因で参照が不安定になり更新に気づけなかったりします。
// ✅ プリミティブ値・関数を個別に取り出す
const user = useUserStore((s) => s.user);
const setUser = useUserStore((s) => s.setUser);もしくは shallow 比較を明示的に指定してください。rork-zustand-shallow-comparison のようなキーワードでプロンプトに追記すると、Rork の生成コードもこちらに寄せられます。詳しい設計パターンは Rorkアプリの状態管理パターン完全ガイド に整理していますので、合わせてご覧いただくと理解が深まります。
よくある原因4: React.memo や useMemo の依存不足
パフォーマンス最適化のつもりで React.memo や useMemo を入れると、依存配列の書き忘れで「古い値のまま固まる」事故が起きます。
// ❌ filter が変わっても再計算されない
const filteredList = useMemo(
() => items.filter((i) => i.active),
[] // ← items も filter も依存に入っていない
);ESLint の react-hooks/exhaustive-deps ルールを有効にしていれば警告が出ますので、Rork でプロジェクトを立ち上げた直後にまずこのルールを ON にしておくことをおすすめします。
// ✅ 依存を正しく指定
const filteredList = useMemo(
() => items.filter((i) => i.active),
[items]
);ついでに、React.memo(Component) でラップしたコンポーネントに毎回新しい関数やオブジェクトを props で渡していると、せっかくのメモ化が効かず再描画されます。props 側も useCallback / useMemo で安定化させるとセットで効果が出ます。
よくある原因5: 非同期処理の後に unmount 済みコンポーネントで setState
画面遷移直後に fetch のレスポンスが返ってきた場合、すでに unmount されているコンポーネントに setState しても、当然画面には反映されません。警告ログ(Can't perform a React state update on an unmounted component)が出ているなら、このパターンを疑ってください。
useEffect(() => {
let alive = true;
(async () => {
const data = await fetch('/api/items').then((r) => r.json());
if (!alive) return; // unmount 後は setState しない
setItems(data);
})();
return () => { alive = false; };
}, []);もしくは AbortController を使って fetch 自体をキャンセルする方法もあります。リスト表示まわりの似たトラブルについては FlatList が空白で何も映らない—Rork アプリのリスト崩れを切り分ける もあわせて確認いただくと、表示が出ない系の切り分けがスムーズになります。
3 分で切り分ける診断フロー
原因を当てずっぽうで探すと沼るので、次の順に確認するのがおすすめです。
- 1. 値が本当に変わっているか: 更新前後に
console.log(prev, next)を挟み、参照まで変わっているか確認する(prev === nextが true なら原因1) - 2. クロージャが古くないか:
useEffect・setTimeout・イベントハンドラ内で state を直接参照していないか見直し、関数形式setStateに置き換えてみる - 3. 再描画は走っているか: コンポーネントの先頭で
console.log('render', props)を入れて、そもそも再描画が呼ばれているか確認する(呼ばれていないなら memo・selector の問題) - 4. 親の state か子の state か: 子で値を変えているつもりで親の state を書き換えていないか、逆もないかを見る
- 5. プラットフォーム依存か: iOS Simulator だけ・Android だけなど再現条件があるなら
rork-ios-android-behavior-difference-fixの切り分けも検討する
再発を防ぐ小さな習慣
「直ったのにまた同じパターンでハマる」を繰り返さないために、私は次の 3 つを習慣にしています。
まず、ESLint の react-hooks/exhaustive-deps を必ず ON にします。Rork が生成したコードには警告が残っていることがあるので、プロジェクト開始時にまとめて潰しておくと後が楽です。
次に、state を更新する関数には必ず関数形式(setX((prev) => ...))を使うルールを自分に課しています。直近の値に依存しない場合も、形を揃えておくとクロージャ起因の事故がぐっと減ります。
最後に、Zustand や Context を使うときは「何を selector で取り出しているか」を必ずプリミティブ単位で見直します。オブジェクトを丸ごと取り出す書き方は、パフォーマンスと挙動の両方で不安定になりがちです。
まずは今ハマっているコードを上の「3 分診断フロー」の順で1つずつチェックしてみてください。大抵は原因1か原因2のどちらかで止まります。切り分けに必要な時間を 1 日から 10 分に短縮できれば、それだけで開発の体験は大きく変わります。