【React×TypeScript】stateを更新するときの注意
はじめに
react-reduxでstateを扱うとき、reducer内でstateの内容を変更したりします。
そこではまったことがあったのでメモ。
開発環境
Windows 10
Node.js 18.15.0
TypeScript 4.9.5
React 18.2.0
Visual Studio 2022
react-redux 8.5.0
値と参照
念のためTypeScriptの基本から書きます。
まず、以下のコードはどう動くでしょうか。
let p1 = 1;
let p2 = p1;
p1 = 2;
// p1: 2
// p2: 1
p1
をp2
に代入した後、p1
の値を書き換えても、p2
には影響ありません。
これは2行目で「値」を代入しており、p1
とp2
は独立した「値」を保持しているためです。
次に、以下のコードはどう動くでしょうか。
let p1 = { x: 1 };
let p2 = p1;
p1.x = 2;
// p1: Object { x: 2 }
// p2: Object { x: 2 }
p1
は、プロパティx
を持ったオブジェクトとして定義されています。
今回はp1.x
への変更がp2.x
に影響しています。
これは2行目で「オブジェクト」を代入しており、オブジェクトとしてp1
とp2
は別物になりますが、中身は同じものを参照しているためです。
スプレッド構文を使って複製
今度は、オブジェクトを完全に複製したい場合を考えます。
上記の例でいうと、プロパティx
の参照をも切り離したい、という場合です。
いくつか方法はありますが、今回は「スプレッド構文」で書いてみます。
let p1 = {x: 1};
let p2 = { ...p1 };
p1.x = 2;
// p1: Object { x: 2 }
// p2: Object { x: 1 }
...
がスプレッド構文。
これにより、オブジェクトが持つプロパティも「値」で代入されるため、p1.x
とp2.x
は別物として複製されることになります。
一見、これで目的は達成したかに見えますが、次の例では思ったように動きません。
let p1 = { x: 1, y: ['A', 'B', 'C'] };
let p2 = { ...p1 };
p1.x = 2;
p1.y[1] = 'Z';
// p1: Object { x: 2, y: Array ["A", "Z", "C"] }
// p2: Object { x: 1, y: Array ["A", "Z", "C"] }
プロパティx
は参照が切り離されて独立していますが、y
は同じ参照先となっています。
なんでやねん。
sharow copyとdeep copy
このサイトに理由が書いています。
メモ: コピーは 1 段階の深さで行われます。
つまり、スプレッド構文では1段階目にあるプロパティをコピーする機能しかなく、もしそれがオブジェクトなら、そのオブジェクト内のプロパティは「参照」を共有したままになっている、ということです。
この「1段階目までコピーする」ことを 浅いコピー(sharow copy) と呼んでいます。
これに対して、「最下層までコピーする」ことを 深いコピー(deep copy) と呼びます。
本稿の趣旨とずれるので細かい説明は省きますが、以下のサイトがわかりやすく解説されています。
では、deep copyしたい場合はどのように書けば良いでしょうか。
それには「structuredClone」というものを使います。
let p1 = { x: 1, y: ['A', 'B', 'C'] };
let p2 = structuredClone(p1);
p1.x = 2;
p1.y[1] = 'Z';
// p1: Object { x: 2, y: Array ["A", "Z", "C"] }
// p2: Object { x: 1, y: Array ["A", "B", "C"] }
これで「オブジェクトを完全に複製する」という目的は達成できました。
ただ、注意点として、そのオブジェクトが超巨大だった場合、まったく同じものが2つできるので処理負荷?メモリ?に負担がかかるということを気にしなければなりません。
reducer内でのstate更新
ここからが本題。
react-reduxでreducerがstateを返す時、今のstateの内容を変更したい場合があります。
引数から渡された現在のstateを直接編集するとエラーが出るので、「コピーしたものを返す」という作法が必要となります。
import { Action } from 'redux';
type Param = {
p1: number,
p2: string
}
type State = {
num: number;
param: Param[];
}
interface Action1 {
type: 'ACTION1';
value: number;
}
interface Action2 {
type: 'ACTION2';
value: string;
}
const reducer: Reducer = (state: State, action: Action) => {
switch (action.type) {
case 'ACTION1':
// state.num = action.value;
// return state;
// -> stateを直接編集しているのでエラー
return { ...state, num: action.value };
// stateをコピーし、同時にプロパティの値を変更する
case 'ACTION2':
// state.param[0].p2 = action.value;
// return state;
// -> stateを直接編集しているのでエラー
// let _param = { ...state.param };
// _param[0].p2 = action.value;
// return { ...state, param: _param };
// -> paramはコピーされたが、中の要素はstateと同じ参照先となっているため
// stateを直接編集しているという扱いになりエラー
// let _param = structuredClone(state.param);
// _param[0].p2 = action.value;
// return { ...state, param: _param };
// -> paramをdeep copyしているので、中の要素を変更しても
// stateを直接編集しているという扱いにはならず正常終了する
return {
...state,
param: state.param.map((value, index) =>
index === 0 ? { ...value, p2: action.value } : value)
};
// -> paramのプロパティをコピーするときもスプレッド構文を使う
default:
return state;
}
}
Action1
はプロパティnum
の値を書き換える、Action2
はプロパティparam
の1つ目の要素のうちp2
を書き換える処理としています。
それぞれ、エラーが出る書き方と、(最適かどうかは別として)正常に動作する書き方を挙げています。
stateを更新しようとしたときに変なエラーが出たりstateが思うように更新されなかったときは、ちゃんとコピーされているかを意識してみましょう。
以上です。
P.S.
開発環境がデバッグ実行できない環境に陥ってしまったため、お試し実行とか実際に出るエラーの確認とかができなくなりました。
本稿の内容は後で書き直すかもしれません。
Discussion