React Monaco Editorのイベントハンドラに登録する際の初期化のタイミング
背景
React でファイルベースのエディタアプリを開発する際、ファイル構造ツリーからファイルを選択し、Monaco Editor でその内容を編集できるような構成は一般的です。
この記事では以下のようなコンポーネント構成を前提に、自動保存の実装時にハマったことの備忘録。(戒めです)
コンポーネント構成
-
OuterLayout(親コンポーネント)
- アプリ全体のレイアウトを管理し、エディタに渡す状態(
selectedFile
)を保持します。
- アプリ全体のレイアウトを管理し、エディタに渡す状態(
-
FileTreeComponent
- ファイル構造を表示し、ファイル選択時にそのパスと内容を取得して
selectedFile
を更新して親に通知します。
- ファイル構造を表示し、ファイル選択時にそのパスと内容を取得して
-
Editor
- Monaco Editor をラップしたコンポーネントで、ファイルの編集機能を提供します。
-
onChange
とonBlur
を通じて編集内容を親に通知します。
実装要件
Monaco Editor 上で入力後、エディタのフォーカスが外れたタイミングで、親コンポーネント(OuterLayout
)が検知してそのファイル内容を自動保存する。
問題
Editor
の onBlur
ハンドラで呼ばれる保存関数 handleSave(newContent)
があります。
その中でselectedFile.path
を呼んでいるにもかかわらず、保存先が content/README.md
に固定されてしまう。
ログを出してみると、なぜか selectedFile.path
が意図したファイルでなく、以前選択していたファイルのパスのままです。
原因
この問題の本質は、Editor
コンポーネントが selectedFile.path
に値が入る前にマウントされていたことにあります。つまり、React は最初のレンダリングで selectedFile.path === ''
の状態で Editor
を描画し、その後の更新を正しく反映してい
ませんでした。
この結果、onBlur
の中で参照していた selectedFile.path
が常に空、または古いパスのままになっていました。
解決策
ライフサイクルの流れを親から正確に整えてあげる!!!(当たり前体操)
条件付きレンダリングで対応
Editor
を selectedFile.path
がセットされたあとにだけ描画するように、条件付きレンダリングを追加しました:
<Box flexGrow={1} display="flex" flexDirection="column">
{selectedFile.path && (
<Editor
filePath={selectedFile.path}
content={selectedFile.content}
onChange={(newContent) => {
setSelectedFile((prev) => ({ ...prev, content: newContent }));
}}
onBlur={(newContent) => {
handleSave(selectedFile.path, newContent);
}}
/>
)}
</Box>
この変更により、ファイルが選択されてから初めて Editor
がマウントされるようになり、常に最新の selectedFile.path
を onBlur
時に取得できるようになりました。
補足:useEffectでもなく条件分岐で対応する理由
useEffect
を使って再設定する方法も考えられますが、MonacoEditor
のように内部状態が複雑なコンポーネントの場合は、マウント時点の props が重要になるケースが多く、props 更新ではなく再マウントが求められる場面があります。
原因がわかると初心者みたいなミスだなぁと思いますが笑
Reactのレンダリングのタイミングとライフサイクルに関する理解が重要だと再認識しました。。。
まとめ
-
onBlur
で親コンポーネントが状態を参照する場合、その値が正しいことを保証しなければならない。 - 特にエディタのような重いコンポーネントでは、初期レンダリング時点の props が継続使用されることに注意が必要。
- 条件付きレンダリングによって、想定された状態のもとで確実にコンポーネントを初期化できる。
Monaco Editor に限らず、状態を props 経由で渡す非制御コンポーネントでは、似たような罠に注意する必要があります。
Discussion