はじめに
- React+ Next.jsで環境構築しています(npx create-next-app@latest)
- コーディングにはVSCodeを使用しています
- 例として、MUIのコンポーネントを使用しています
- 役立つかな?って感じで調べたことについてまとめています
要約
問題が発生する条件
- ダイアログが開く時に親から初期値を受け取る場合
- かつ、その初期値をダイアログ内のstateの初期値に指定する場合
問題点
- 親の値を初期値から変えずにダイアログを開く
- ダイアログの初期値は問題ない
- 親の値を変えてダイアログを開く(defaultValue -> newValue)
- ダイアログの初期値は親の初期値になる(defaulValue)
解決法
- a.stateを親で管理させる
- b.ダイアログを開くたびに再マウントさせる
詳細
前提として、
- 親(pageなど)がstateで値を保持している
- 子(Dialogなど)がstateで値を保持しており(フォームとか)、親から初期値に受け取る
この構造を持つとする。
前提を作成していく
最小単位くらいのサイズで以下のように作成します
/** フォームを持つサンプルダイアログ */
function SampleDialog({ initialValue, open, onClose }: SampleDialogType) {
const [value, setValue] = useState<string>(initialValue); // ここで親から初期値を受け取る
const onChange = useCallback((e: SelectChangeEvent) => {
const newValue = e.target.value;
setValue(newValue);
}, []);
const onSubmit = useCallback(() => console.log("データ送信", value), [value]);
return (
<Dialog open={open} onClose={onClose}>
<Select value={value} onChange={onChange}>
<MenuItem value={"ばりゅー1"}>ばりゅー1</MenuItem>
<MenuItem value={"ばりゅー2"}>ばりゅー2</MenuItem>
<MenuItem value={"ばりゅー3"}>ばりゅー3</MenuItem>
</Select>
<Button onClick={onSubmit}>送信</Button>
</Dialog>
);
}
/** レンダーとマウントのお試しコンポーネント */
export default function RenderMountTest() {
// セレクト関連
const [value, setValue] = useState<string>("ばりゅー1");
const onChange = useCallback((e: SelectChangeEvent) => {
const newValue = e.target.value;
setValue(newValue);
}, []);
// ダイアログ関連
const [open, setOpen] = useState<boolean>(false);
const onOpen = useCallback(() => setOpen(true), []);
const onClose = useCallback(() => setOpen(false), []);
return (
<>
<Select value={value} onChange={onChange}>
<MenuItem value={"ばりゅー1"}>ばりゅー1</MenuItem>
<MenuItem value={"ばりゅー2"}>ばりゅー2</MenuItem>
<MenuItem value={"ばりゅー3"}>ばりゅー3</MenuItem>
</Select>
<div>だいあろぐを開く</div>
<Button onClick={onOpen}>ボタン!</Button>
<SampleDialog initialValue={value} open={open} onClose={onClose} />
</>
);
}
同期しない問題
まず今回の動作の想定としては
- ダイアログを開く
- 親のvalueがDialogのvalueに表示される
こういった感じを想定しています。
ですが、そうはならないです。
// 初期状態(同期できている!)
// 親側
console.log(value); // ばりゅー1
// 子側
console.log(value); // ばりゅー1
// 親のvalueを"ばりゅー2"に変更した場合(同期できてない!)
// 親側
console.log(value); // ばりゅー2
// 子側
console.log(value); // ばりゅー1
理由はuseStateの仕様が原因です。
- useStateの仕様として、初期値は最初のレンダー時にのみ取得します
- open={false}なので画面上には表示されていませんが、親がレンダーされる時子であるDialogもレンダーされています。(仮想DOM上にはある、というような状態)
つまり、ページを開く(value="ばりゅー1") -> ダイアログの初期値(initialValue="ばりゅー1")
このように渡されたのちに親の値が変更されたとしてもダイアログのstateの値は影響されない、ということです。
では、このダイアログの値を変えるには?というのは簡単で、useStateの値を変えるのはそのset関数を使う以外ないです。(一応直接やれないこともないけど、これはreactのアンチパターンなので除外)
問題解決方法
a.親にStateを移動させてみる
じゃーset関数でやってみよう!となると、stateをリフトアップする必要があり、かなりややこしくなります。(ディスパッチャーとかあればもうちょいまとまるかも?だけど)
- ダイアログのvalueと親のvalueを同期させる
- 親がダイアログの値を変更できるようにする必要がある
- 親がダイアログの値をstate管理する必要がある
やってみると以下の感じ
コード
まずダイアログ
/** フォームを持つサンプルダイアログ */
function SampleDialog({ open, onClose, value, onChange }: SampleDialogType) {
// valueもonChangeも親が管理するようになる
const onSubmit = useCallback(() => console.log("データ送信", value), [value]);
return (
<Dialog open={open} onClose={onClose}>
<Select value={value} onChange={onChange}>
<MenuItem value={"ばりゅー1"}>ばりゅー1</MenuItem>
<MenuItem value={"ばりゅー2"}>ばりゅー2</MenuItem>
<MenuItem value={"ばりゅー3"}>ばりゅー3</MenuItem>
</Select>
<Button onClick={onSubmit}>送信</Button>
</Dialog>
);
}
ぺーじがわ
/** レンダーとマウントのお試しコンポーネント */
export default function RenderMountTest() {
const initialValue = "ばりゅー1"; // 初期値を定義
// ダイアログのセレクト関連
const [dialogValue, setDialogValue] = useState<string>(initialValue);
// ページとダイアログの値を同期させる関数
const doSynchroDialogValue = useCallback(
(value: string) => setDialogValue(value),
[]
);
const onChangeDialog = useCallback((e: SelectChangeEvent) => {
const newValue = e.target.value;
setDialogValue(newValue);
}, []);
// セレクト関連
const [value, setValue] = useState<string>(initialValue);
const onChange = useCallback((e: SelectChangeEvent) => {
const newValue = e.target.value;
setValue(newValue);
}, []);
// ダイアログ関連
const [open, setOpen] = useState<boolean>(false);
const onOpen = useCallback(() => {
setOpen(true);
doSynchroDialogValue(value); // ダイアログを開く際にvalueを同期させる
}, [doSynchroDialogValue, value]);
const onClose = useCallback(() => setOpen(false), []);
return (
<>
<Select value={value} onChange={onChange}>
<MenuItem value={"ばりゅー1"}>ばりゅー1</MenuItem>
<MenuItem value={"ばりゅー2"}>ばりゅー2</MenuItem>
<MenuItem value={"ばりゅー3"}>ばりゅー3</MenuItem>
</Select>
<div>だいあろぐを開く</div>
<Button onClick={onOpen}>ボタン!</Button>
<SampleDialog
initialValue={value}
open={open}
onClose={onClose}
value={dialogValue}
onChange={onChangeDialog}
/>
</>
);
}
これで動作しますが、以下の点で注意が必要です。
- ダイアログが親に依存しすぎる
- ダイアログの値が変更されると、ページ全体が再レンダリングされる(ページがでかい場合はパフォーマンス面でマイナス)
- ダイアログ内のロジックの所在がわかりにくい(なんとなく、イメージとしてはダイアログがもってそうな感じするのに、実際の所在はページであるため)
b.必要に応じてマウント/アンマウントさせる
もう一つの簡単な方法として、ダイアログを開閉時に完全にアンマウントさせる方法があります。
初めてマウントされる時にstateの初期値は1度だけ利用される
=>開閉のたびに初めてレンダーさせれば毎回初期値を取得できる
といったイメージです。
以下で実装できます。
{open && (
<SampleDialog initialValue={value} open={open} onClose={onClose} />
)}
これでopen=falseの場合はダイアログがDOMから消滅します。一方でopen=false -> trueへ切り替わるとダイアログが作成されてstateの初期値に親の現在のvalueが与えられます。
以下のようにconsole.logを追記するとDOMから削除されているのが確認できると思います。
上を設定しない場合は親のセレクトを弄るたびに子コンポーネントもすべて再レンダーされるので、コンソールメッセージが表示されますが、上を設定するとそもそもレンダーされていないのでコンソールメッセージが表示されません。
function SampleDialog({ initialValue, open, onClose }: SampleDialogType) {
console.log("更新")
// ...
各方法のメリット・注意点
stateで管理させる場合
- メリット
- ダイアログの内容を保持できる
- ユーザーが誤って閉じた場合に途中からスタートできる
- 頻繁に開くなら常にマウントされてるとレスポンスが早くなる?と思う
- ダイアログの内容を保持できる
- 注意点
- 非表示であっても親が再レンダーされると更新される
- そのために最適化をしっかりする必要がある
- 使い回す際にロジックの所在がダイアログの外にあるのでわかりづらい
- 非表示であっても親が再レンダーされると更新される
マウント/アンマウント させる場合
- メリット
- ダイアログの内容をダイアログ内で完結できる
- ダイアログを操作してもダイアログ内のみ再レンダーされる
- 所在がわかりやすい
- 使い回ししやすい
- 非表示の場合に親が再レンダーされた場合に再レンダー処理されない
- パフォーマンスが向上する
- ダイアログの内容をダイアログ内で完結できる
- 注意点
- 閉じるたびに初期化される
- 誤って閉じた場合に内容が全て失われる
- stateを持たない場合(表示する内容が固定されている場合)
- 毎回アンマウントするとDOMの処理が増えそう
- 閉じるたびに初期化される
おわりに
これについてはダイアログに限らず、メニューアイテムやポップアップなども同様のことが言えます。
stateの管理で問題がおきるとしたらダイアログでくらいかな?と言った感じですが、最適化を考える場合にもこの方法は応用が効くと思うので、気になったら試してみてもいいと思います。
正直、マウント/アンマウントの実際のコストはよくわからないので「呼び出す頻度が少ない」「複数の処理がある」場合なんかのときに使うのがいいのかな?って感じで考えてます。