🐕

【React×TypeScript】stateを更新するときの注意

2023/05/26に公開

はじめに

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

p1p2に代入した後、p1の値を書き換えても、p2には影響ありません。
これは2行目で「値」を代入しており、p1p2は独立した「値」を保持しているためです。

次に、以下のコードはどう動くでしょうか。

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行目で「オブジェクト」を代入しており、オブジェクトとしてp1p2は別物になりますが、中身は同じものを参照しているためです。

スプレッド構文を使って複製

今度は、オブジェクトを完全に複製したい場合を考えます。
上記の例でいうと、プロパティxの参照をも切り離したい、という場合です。
いくつか方法はありますが、今回は「スプレッド構文」で書いてみます。

let p1 = {x: 1};
let p2 = { ...p1 };
p1.x = 2;
// p1: Object { x: 2 }
// p2: Object { x: 1 }

...がスプレッド構文。
これにより、オブジェクトが持つプロパティも「値」で代入されるため、p1.xp2.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

このサイトに理由が書いています。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax

メモ: コピーは 1 段階の深さで行われます。

つまり、スプレッド構文では1段階目にあるプロパティをコピーする機能しかなく、もしそれがオブジェクトなら、そのオブジェクト内のプロパティは「参照」を共有したままになっている、ということです。
この「1段階目までコピーする」ことを 浅いコピー(sharow copy) と呼んでいます。
これに対して、「最下層までコピーする」ことを 深いコピー(deep copy) と呼びます。
本稿の趣旨とずれるので細かい説明は省きますが、以下のサイトがわかりやすく解説されています。
https://zenn.dev/luvmini511/articles/722cb85067d4e9

では、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