ある面接で、AIを使ったReactのリファクタリングが課題として出された。
面接官が渡したのは、2017年に書かれたClassコンポーネントだった。propsは17個、stateには5個のboolean、ライフサイクルには4つの非同期処理が入っている。
そして課題はこうだった。
25分で、AIを使ってリファクタリングする。コードそのものは見ない。どう分解するかを見る。
ここで「8年前のReact」が出てくるのは、長年使われたコードの歴史を読み解くという話ではない。面接で、古い書き方と複雑な状態管理が残ったコンポーネントを題材として渡されたからだ。
受験者は最初、Prompt Engineeringの問題だと考え、「このClassコンポーネントを関数コンポーネントへ変えて、パフォーマンスも改善して」とAIへ依頼した。すると useEffect や useState を使った、2026年らしい見た目のコードがすぐに出てきた。
ところが面接官が聞いたのは、「元のバグはどこにあったのか」だった。
問題は、Class ComponentをFunction Componentへ変えられるかどうかではなかった。古いコードの中で、どこに依存関係があり、どのstateが絡み、どの副作用が危険なのかを見抜いてから変更できるかが見られていた。
この話はAI Codingを題材にしているが、根本はAIの話ではない。
古いClassコンポーネントをFunction Componentへ変える。古いAPIを新しいAPIへ置き換える。大きなファイルを複数のファイルへ分割する。どれも見た目は変わるが、依存関係や状態の持ち方、副作用の扱いが変わっていなければ、元からあった問題も一緒に移動する。
リファクタリングで厄介なのは、コードを書くことより、どの振る舞いを残し、どの関係を切ってよいのかを判断することだ。
ClassをFunctionに変えても、構造は良くならない
ReactではClass ComponentからFunction Componentへ移行すると、コードの形はかなり変わる。
componentDidMount や componentDidUpdate が useEffect になり、this.state は useState などへ置き換わる。メソッドのbindも不要になる。
しかし、構文が新しくなったことと、設計上の問題が解消したことは別だ。
たとえば、propsで受け取った値をstateへコピーし、そのstateから別のstateを作り、さらにコールバックから元の値を書き換えるような構造があったとする。
props.user
↓
state.user
↓
state.displayName
↓
onChange
↓
別の処理からstate.userを更新
これをそのままHooksへ変換すると、依存関係は残ったまま useState と useEffect が増える。場合によっては、Class Componentだった頃より追いにくくなる。
Class Componentであること自体が問題なのではない。最初に見るべきなのは、そのコンポーネントの中でデータと処理がどう結び付いているかだ。
最初に見るのは3つの関係
古いコンポーネントを触るときは、コードを変更する前に次の3つを見ると全体像を掴みやすい。
| 見るもの | 確認する内容 |
|---|---|
| データの流れ | props、state、Context、外部Storeのどこから値が来て、どこで変更されるか |
| 副作用 | API通信、タイマー、イベントリスナー、DOM操作、Storageなど |
| 描画条件 | loading、error、権限、編集状態など、何によって表示が切り替わるか |
たとえば userId というpropsがあるだけでも、実際には次のような経路を持っていることがある。
userId
↓
APIリクエスト
↓
user state
↓
権限判定
↓
表示するボタンが変わる
↓
保存時のリクエスト内容も変わる
この状態で fetchUser() だけを別のHookへ移しても、コンポーネント全体の結合はあまり減らない。
一方で、データ取得、権限判定、編集状態が別々の変更理由を持っていると分かれば、どこから切るべきかも見えてくる。
propsとstateの二重管理は最初に疑う
Reactの公式ドキュメントでも、propsや既存のstateから計算できる値を別のstateとして持つことや、同じ情報を複数のstateへ重複して保持することは避けるよう案内されている。
Choosing the State Structure – React
The library for web and native user interfaces
https://react.dev/learn/choosing-the-state-structure
たとえば次のコードでは、messageColor をpropsから受け取ったあと、同じ値をstateにも保存している。
function Message({ messageColor }) {
const [color, setColor] = useState(messageColor);
return <p style={{ color }}>Hello</p>;
}
このstateは初回レンダー時に初期化されるだけなので、親から新しい messageColor が渡されても自動では同期されない。
変更可能な値として別管理する意図がなければ、propsをそのまま使えばよい。
function Message({ messageColor }) {
return <p style={{ color: messageColor }}>Hello</p>;
}
古いコードでは、「昔は必要だったstate」がそのまま残っていることもある。リファクタリングでは新しい書き方へ移す前に、そのstate自体がまだ必要なのかを確認したい。
暗黙の前提をコードの外へ逃がさない
長く使われているコードには、実装には書かれていない前提が増えやすい。
たとえば次の処理だけを見ると単純だ。
componentDidMount() {
if (this.props.userId) {
fetchUser(this.props.userId).then((user) => {
this.setState({ user });
});
}
}
しかし、このコードが安全に動くためには、少なくともいくつかの前提がある。
userIdはfetchUserが受け付ける形式であるfetchUserは期待するUserを返す- Userが見つからない場合の扱いが決まっている
- 通信に失敗した場合の表示が決まっている
userIdが途中で変わった場合、古いレスポンスをどう扱うか決まっている- コンポーネントが不要になったあと、進行中の処理をどう扱うか決まっている
古い実装では、上位コンポーネントやAPIの都合によって偶然守られていた条件もある。
そのため、型を付けるだけでは足りないことがある。入力値の検証、エラー時の分岐、テスト、API仕様など、前提に応じた場所へ戻していく必要がある。
AIを使う場合も、ここで役に立つのはコード生成より先に、「この処理が成立するために暗黙に仮定している条件を列挙する」といった読み取りの補助だ。
分割は見た目より副作用の境界から考える
大きなReactコンポーネントを見ると、最初にHeader、Form、Footerのような見た目の単位へ分けたくなる。
ただ、3つのコンポーネントが同じstateや同じAPI処理へ強く依存しているなら、ファイル数が増えただけで結合は残る。
UserPage
├─ Header ─┐
├─ Form ─┼─ 同じuser stateを更新
└─ Footer ─┘
先に検討したいのは、API通信やタイマー、イベントリスナーといった外部との同期処理だ。
useUser
└─ user取得と通信状態
UserCard
└─ 表示
UserEditor
└─ 編集操作
データ取得をHookへ移すこと自体が目的ではない。通信の開始条件、成功、失敗、破棄時の処理を1か所で追えるようにするために境界を作る。
非同期処理を分けるならcleanupまで含める
useEffect へ移しただけでは、非同期処理の競合はなくならない。
Reactの公式ドキュメントでも、Effect内でデータを取得する場合、cleanupでリクエストを中断するか、不要になった結果を無視する方法が案内されている。
useEffect – React
The library for web and native user interfaces
https://react.dev/reference/react/useEffect
以下は構造を示す簡略例で、実プロジェクトでの動作確認はしていない。
import { useEffect, useState } from "react";
function useUser(userId) {
const [state, setState] = useState({
status: "idle",
user: null,
error: null,
});
useEffect(() => {
if (!userId) {
setState({
status: "idle",
user: null,
error: null,
});
return;
}
let ignore = false;
setState({
status: "loading",
user: null,
error: null,
});
fetchUser(userId)
.then((user) => {
if (!ignore) {
setState({
status: "success",
user,
error: null,
});
}
})
.catch((error) => {
if (!ignore) {
setState({
status: "error",
user: null,
error,
});
}
});
return () => {
ignore = true;
};
}, [userId]);
return state;
}
ここでは古い userId に対するレスポンスが後から返ってきても、新しい状態を上書きしないようにしている。
fetchUser が AbortSignal を受け取れるなら、AbortController でリクエスト自体を中断する方法もある。
booleanを減らす目的は「数」ではなく矛盾をなくすこと
古いUIでは、状態がbooleanとして増えていることが多い。
{
loading: false,
loaded: true,
hasError: true,
saving: false
}
この形では、loaded: true と hasError: true が同時に成立してよいのか、コードだけでは分かりにくい。
リクエストの状態が排他的なら、1つの値へまとめたほうが扱いやすい。
status: "idle" | "loading" | "success" | "error"
ただし、すべてのbooleanを無理に1つへ詰め込む必要はない。
たとえば「ユーザー情報の取得状態」と「編集モードかどうか」は別の関心事だ。
requestStatus: "idle" | "loading" | "success" | "error"
editing: true | false
状態数を何個以下にする、といった機械的な基準より、同時に成立してはいけない状態を表現できてしまっていないかを見るほうが実用的だ。
小さく変更し、毎回同じ振る舞いを確認する
大きなコンポーネントを一度に書き直すと、差分のどこで挙動が変わったのか追いにくい。
変更を小さく区切ると、確認対象も限定できる。
- 現在の入力と出力、主要な表示分岐を記録する
- 暗黙の前提を型・テスト・分岐のどこで保証するか決める
- API通信など、副作用を1つだけ分離する
- その変更で既存の振る舞いが変わっていないことを確認する
- 次の境界へ進む
- ClassからFunctionへの変換は、必要なら最後に行う
ここでいう「同じ振る舞い」には、正常系だけでなく、loading、error、入力値なし、連続操作なども含まれる。
既存コードの正しさに自信がない場合は、まず現状の挙動を固定するcharacterization testを置き、そのあとで内部構造を変える方法も使える。
リファクタリング前に見るチェック項目
リファクタリング前に見る項目をまとめると、次のようになる。
数値で一律に制限するより、各段階で何を確認できたら次へ進めるのかを決めておくほうが実務では使いやすい。
| 項目 | 確認すること | 次へ進む条件 |
|---|---|---|
| 依存関係 | props、state、Context、外部Store、外部関数 | 値の入口と変更箇所を追える |
| 暗黙の前提 | null、型、権限、API応答、コールバックの存在 | どこで保証するか決まっている |
| 副作用 | 通信、タイマー、イベント、DOM、Storage | 開始とcleanupを追える |
| state | 重複、派生値、矛盾するboolean | 不要なstateを減らせている |
| 描画条件 | loading、error、empty、権限、編集状態 | 既存の分岐を列挙できる |
| 回帰確認 | 変更前後の入力と表示・出力 | 期待する振る舞いが一致する |
| 性能 | 再レンダー回数、処理時間、通信回数 | 計測した問題だけを対象にする |
| 削除 | 使われなくなった関数、state、props | 参照がないことを確認できる |
この表の目的は、きれいなコードを作ることではない。
変更してよい場所と、まだ触るべきではない場所を分けるために使う。
AIへ頼む場合も、受け入れ条件は小さくする
AIへ「このコンポーネントをリファクタリングして」とだけ頼むと、構文変換、状態管理の変更、命名変更、ファイル分割、最適化まで同時に行われることがある。
差分が大きくなるほど、人間側で「何が変わったのか」を確認するコストも増える。
依頼するなら、変更対象と確認条件を狭くしたほうがよい。
このコンポーネントのデータ取得処理だけをuseUserへ分離する。
条件:
- 描画するJSXは変更しない
- 既存のpropsインターフェースは変更しない
- userId変更時に古いレスポンスが新しい状態を上書きしない
- loading / success / errorの既存分岐を維持する
- 変更した振る舞いを確認できるテストを追加する
これはPrompt Engineeringというより、普段のIssueやPull Requestで変更範囲とAcceptance Criteriaを定義するのと近い。
AIがなくても同じ条件は必要で、AIを使うと変更を生成する速度が上がるぶん、むしろ境界を曖昧にしないほうが扱いやすい。
リファクタリングは、書き換える前の観察でほぼ決まる
面接で渡されたのは8年前のReactコードだったが、課題の焦点はその年数ではない。古いClassコンポーネントを前にしたとき、いきなり新しい構文へ変換するのではなく、先に問題を見つけて分解できるかが問われていた。
Class ComponentをFunction Componentへ変えること自体は難しくない。AIを使えば、そこはさらに速くできる。
難しいのは、絡まった依存関係から「残すべき振る舞い」と「切り離せる関係」を見つけることだ。
Class ComponentをFunction Componentへ変えるのは、そのあとでも遅くない。
先にデータの流れ、副作用、描画条件、暗黙の前提を追う。重複したstateを減らし、変更を小さく区切り、毎回挙動を確認する。
この手順はAI Codingが普及しても変わらない。むしろコードを書き換える速度だけが上がった今ほど、どこから切るかを判断する工程の価値が大きくなる。