【React】JavaScriptのイミュータビリティを理解して正しくState更新する

JavaScript

JavaScriptのイミュータブル(immutable)やミュータブル(mutable)の意味、ReactのState更新で重要なイミュータビリティ(immutability)について紹介します。

事前知識: データの更新方法について

データの更新方法には『データの参照先の値を上書きする方法』と『データの参照先を変更して値を置き換える方法』の2つがあります。

書き換えを意味するmutate(ミューテート)という英単語を用いて、前者は『ミューテートを伴うデータ更新』、後者は『ミューテートを伴わないデータ更新』と呼ばれています。1

ミューテートを伴うデータ更新の例は以下の通りです。

type Player = {
  score: number,
  name: string,
};

const player: Player = { score: 1, name: 'Jeff' };

// ミューテートを伴う更新(参照先の値を上書きする)
const changePlayer = (player: Player) => {
  player.score = 2
  return player;
}

// ミューテートを伴う更新のため、newPlayerとplayerの参照先は同じ
const newPlayer = changePlayer(player)

console.log(newPlayer)
// { score: 2, name: 'Jeff' }

// 参照先の値が上書きされたため、newPlayerの値も更新される
console.log(player)
// { score: 2, name: 'Jeff' }

ミューテートを伴わないデータ更新の例は以下の通りです。

type Player = {
  score: number,
  name: string,
};

const player: Player = { score: 1, name: 'Jeff' };

// ミューテートを伴わない更新(参照先を変更して値を更新)
const changePlayer = (player: Player) => Object.assign({}, player, { score: 2 })

// ミューテートを伴わない更新のため、newPlayerとplayerの参照先は別
const newPlayer = changePlayer(player)

console.log(newPlayer)
// { score: 2, name: 'Jeff' }

// 参照先は別のためplayerの値は変わらない
console.log(player)
// { score: 1, name: 'Jeff' }

イミュータブルとミュータブルについて

イミュータブル(immutable)なデータとは『一度作成したら値を変更できない』性質、ミュータブル(mutable)なデータとは『一度作成した後も値が変更できる』性質を持つデータです。

プリミティブ型はイミュータブル

JavaScriptのプリミティブ型は以下の通りです。

プリミティブ型は以下の通りです。2

  • 真偽値
  • 数値
  • 巨大な整数
  • 文字列
  • undefined
  • null
  • シンボル

let num = 3に対してnum += 1を実行するとnumは3から4にかわるため「プリミティブ型はミュータブルではないのか?」と思うかもしれません。
しかしnumの値が変わったのはnumの参照先の値が変更されたからではなく、numの参照先が数値『3』から数値『4』に変更されたためです。
参照先の値は上書きできないため、プリミティブ型はイミュータブルです。

オブジェクト型はミュータブル

JavaScriptのオブジェクトの例は以下の通りです。2

  • Object
  • 配列
  • 関数
  • 正規表現
  • Date

オブジェクト型の値を直接変更した場合は『ミューテートを伴う更新』、データをコピーして参照先を変更してから値を更新した場合は『ミューテートを伴わない更新』となります。

イミュータビリティについて

イミュータビリティ(immutability、不変性)とは参照先のデータが不変であることを意味します。

プリミティブ型はイミュータブルな性質を持つため、プリミティブ型のデータ更新ではイミュータビリティが保証されています。
一方、オブジェクトはミュータブルな性質を持つため、オブジェクトのデータ更新ではイミュータビリティが保証されていません。

React・ReduxのStateの更新はミューテートを伴わない方法で行う

React・ReduxのStateの更新はミューテートを伴わない方法、つまり参照先の値を上書きしないイミュータビリティを保証した方法が推奨されています。

ReactのState更新でミューテートをしないメリットは以下の通りです。1

ミューテートを伴わないState更新のメリット
  • 複雑な機能が簡単に実装できる
  • Stateの更新を検出できる
  • Reactの再レンダーが正常に機能する

State更新の正しい方法と誤った方法の具体例

React・ReduxのState更新は、イミュータビリティが保証されている状態、つまり参照先の値を上書きするのではなく、参照先を変更してから値を変更するのが正しい方法です。

以下ではケースに分けてStateの正しい更新方法と誤った更新方法の具体例を紹介します。今回紹介するケースは以下の通りです。

  • ネスト構造を持たないオブジェクトのプロパティを更新する場合
  • ネスト構造を持つオブジェクトのオブジェクト型プロパティを更新する場合
  • 配列の要素を追加する場合

ネスト構造を持たないオブジェクトのプロパティを更新する場合

誤った方法

オブジェクトの場合は直接プロパティの値を更新するとイミュータビリティが保証されません。

type State = {
  score: number,
  name: string,
};

const prevState: State = { score: 1, name: 'Jeff' };

const newState = prevState
newState.score = 2

console.log(newState)
// { score: 2, name: 'Jeff' }

// ミューテートを伴うため、変更前のデータも更新されてしまう
console.log(prevState)
// { score: 2, name: 'Jeff' }

正しい方法

Object.assignやスプレッド演算子を利用してStateを浅いコピーし、コピーしたStateの値を更新します。
浅いコピーの詳細解説はJavaScriptの浅い比較・浅い(深い)コピーの挙動まとめで紹介しています。

Object.assignを利用した場合は以下のようになります。

// (略)

const prevState: State = { score: 1, name: 'Jeff' };

const newState = Object.assign({}, prevState)
newState.score = 2

// 以下のようにまとめても書ける
// const newState = Object.assign({}, prevState, { score: 2 })

スプレッド演算子を利用した場合は以下のようになります。

// (略)

const prevState: State = { score: 1, name: 'Jeff' };

const newState = { ...prevState }
newState.score = 2

// 以下のようにまとめても書ける
// const newState = { ...prevState, score: 2 }

ネスト構造を持つオブジェクトのオブジェクト型プロパティを更新する場合

誤った方法

ネスト構造を持つオブジェクトを浅いコピーした場合、オブジェクト型のプロパティは参照先がコピーされます。
ですので、浅いコピーを利用しているにもかかわらずミューテートを伴う更新となってしまいます。

type TeamType = {
  ranking: number
}

type State = {
  score: number,
  name: string,
  team: TeamType,
};

const prevState: State = { score: 1, name: 'Jeff', team: { ranking: 3 } };

const newState = Object.assign({}, prevState)
newState.team.ranking = 2

console.log(newState)
// { score: 1, name: 'Jeff', team: { ranking: 2 } };

// ミューテートを伴うため、変更前のデータも更新されてしまう
console.log(prevState)
// { score: 1, name: 'Jeff', team: { ranking: 2 } };

正しい方法1: 深いコピーを利用する

lodashJSON.parse(JSON.stringify(object))を利用した深いコピーを利用します。
深いコピーの詳細解説はJavaScriptの浅い比較・浅い(深い)コピーの挙動まとめで紹介しています。

// (略)

const prevState: State = { score: 1, name: 'Jeff', team: { ranking: 3 } };

const newState = JSON.parse(JSON.stringify(prevState))
newState.team.ranking = 2

正しい方法2: 階層ごとにスプレッド演算子を用意してコピーする

オブジェクトの階層ごとにスプレッド演算子を利用した浅いコピーをすることで、参照先ではなく値がコピーできます。

// (略)

const prevState: State = { score: 1, name: 'Jeff', team: { ranking: 3 } };

const newState = { ...prevState, team: { ...prevState.team } }
newState.team.ranking = 2

// 以下のようにまとめても書ける
// const newState = { ...prevState, team: { ...prevState.team, ranking: 2 } }

配列の要素を追加する場合

誤った方法

Array.prototype.push()は配列を上書きするため、Stateの更新で使用する際は注意が必要です。

type TeamType = {
  ranking: number
}

type Member = {
  score: number,
  name: string,
}

type State = {
  members: Member[]
};

const prevState: State = {
  members: [
    { score: 1, name: 'Jeff' }
  ]
};

// pushによる配列の上書き
prevState.members.push({ score: 0, name: 'Tom' })

const newState = {
  members: prevState.members
}

console.log(newState)
// "members": [
//   { "score": 1, "name": "Jeff" },
//   { "score": 0, "name": "Tom" }
// ]

console.log(prevState)
// "members": [
//   { "score": 1, "name": "Jeff" },
//   { "score": 0, "name": "Tom" }
// ]

正しい方法1: ミューテートを伴わないメソッドで配列の追加を行う

Stateの更新で配列を追加する際はpush()の代わりにArray.prototype.concat()を利用するとよいです。

// (略)

const prevState: State = {
  members: [
    { score: 1, name: 'Jeff' }
  ]
};

const newState = {
  members: Array.from(prevState.members).concat({ score: 0, name: 'Tom' })
}

console.log(newState);
// "members": [
//   { "score": 1, "name": "Jeff" },
//   { "score": 0, "name": "Tom" }
// ]

console.log(prevState);
// "members": [
//   { "score": 1, "name": "Jeff" }
// ]

正しい方法2: 配列を追加する前に浅いコピーを行う

Object.assign([], array)やスプレッド演算子、Array.from()Array.prototype.slice()を利用して配列の浅いコピーをしてから配列の更新をします。

Object.assignを利用した例は以下の通りです。

// (略)

const prevState: State = {
  members: [
    { score: 1, name: 'Jeff' }
  ]
};

const newMembers = Object.assign([], prevState.members)
newMembers.push({ score: 0, name: 'Tom' })

const newState = {
  members: newMembers
}

スプレッド演算子を利用した例は以下の通りです。

// (略)

const prevState: State = {
  members: [
    { score: 1, name: 'Jeff' }
  ]
};

const newMembers = [...prevState.members]
newMembers.push({ score: 0, name: 'Tom' })

// 以下のようにまとめても書ける
// const newMembers = [...prevState.members, { score: 0, name: 'Tom' }]

const newState = {
  members: newMembers
}

Array.from()を利用した例は以下の通りです。

const prevState: State = {
  members: [
    { score: 1, name: 'Jeff' }
  ]
};

const newMembers = Array.from(prevState.members)
newMembers.push({ score: 0, name: 'Tom' })

const newState = {
  members: newMembers
}

Array.prototype.slice()を利用した例は以下の通りです。

const prevState: State = {
  members: [
    { score: 1, name: 'Jeff' }
  ]
};

const newMembers = prevState.members.slice()
newMembers.push({ score: 0, name: 'Tom' })

const newState = {
  members: newMembers
}

さいごに

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考資料