忘れていたので追記( 2025/03/28 )
Immer というライブラリを使えば、破壊的な書き方をしても、勝手に非破壊的な更新が可能になります。
(Redux Toolkit を使う機会が無いので)最近は使ってないけど、別に使っても問題ない……はず?
はじめに
JavaScriptの配列操作には、元の配列を直接変更する「破壊的(destructive)」なメソッドと、元の配列は変更せず、一部が元と異なる《新しい》配列を生成する「非破壊的(non-destructive)」なメソッドがあります。
「破壊的か非破壊的か」を意識べきケースの例として、React があります。React はオブジェクトの同一性のみによって《画面の状態が更新されたか》を判定するので、配列などのオブジェクトを set 関数で更新する場合には、非破壊的に変更する必要があります。
かつては、非破壊的操作のメソッドが十分にそろっていなかったので、非破壊的操作が必要な場合に「古い配列をコピーしてから、新しい配列に対して破壊的操作をする」ことでしのぐことが多くありました。
しかし、今は違います。
主に ES2023 で「配列の非破壊的な変更」のメソッド群が追加され、サポートが手厚くなりました。
この記事では、典型的な配列の操作のパターンに対応する、破壊的なメソッドと非破壊的なメソッドを、両者比較しながら説明します。
この記事の解説には、「iOS 16 未満では未サポート」など、サポートするブラウザの条件によっては使えないメソッドが含まれます。
https://github.com/zloirock/core-js などのポリフィルを入れることで対応可能です。フレームワークごとの設定方法を参考にしつつ、プロジェクトごとのルールに従ってうまく対応してください。
非破壊メソッドの殿堂入り filter
filter
メソッドについては、すでにかなり有名な関数なので、語ることはありません。
['a', 'xx'].filter((s) => s.length > 1);
// 非破壊的: ['xx']
スルーして次のメソッドを紹介します。
配列の要素の変更 a[i] = newValue → with
arr[index] = value
のように直接要素を変更すると、元の配列が書き換わります。一方、ES2023で導入された with()
メソッドを使うと、新しい配列を作成できます。
const arr = ['a', 'b', 'c'];
arr[1] = 'x'; // 破壊的: arrは ['a', 'x', 'c'] になる
const newArr = ['a', 'b', 'c'].with(1, 'x'); // 非破壊的: ['a', 'x', 'c']
ちなみに、with(-1, 'x')
のように、「末尾から数えて◯番目の項目を更新する」ことも可能です。詳しくはドキュメントを参照してください。
const newArr_negative = ['a', 'b', 'c'].with(-1, 'x'); // 非破壊的: ['a', 'b', 'x']
余談ですが、with()
と対になるメソッドとして at()
があります。
arr[i] = newValue
と arr[i]
が対になっているのと同様に、「更新」と「読み出し」の関係です。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Array/at
1件しか更新しないので map を使うまでもない
with()
が無かった頃は、 map()
を使って実装することも多かったのではないでしょうか?
先ほどのようなケースで map()
を使うと、「実現したいこと」に対して遠回りな書き方になっていましたが、with()
ならストレートな書き方にできます。
const newArr = ['a', 'x', 'c'].map((it, i) => i === 1 ? 'x' : it)
配列の(末尾|先頭)の(追加|削除)
末尾への追加 push → concat / スプレッド構文
push()
は元の配列を変更しますが、concat()
やスプレッド構文を使うと新しい配列を作成できます。
どちらで書いても効果は同じです。ただ、メソッドチェーンの途中であれば concat()
が少しだけスッキリと書けるかと思いますが、個人的には、配列を数直線っぽく図形的に捉えることを可能にしてくれるスプレッド構文のほうが好みです。
スプレッド構文の強力さは、この記事の残りの部分でも複数回登場することからも分かると思います。
// 破壊的な末尾への追加
const arr1 = ['a', 'b'];
arr1.push('c', 'd'); // arr1は ['a', 'b', 'c', 'd'] になる
// 非破壊的な末尾への追加
const newArr1 = ['a', 'b'].concat(['c', 'd']); // ['a', 'b', 'c', 'd']
const newArr2 = [...['a', 'b'], 'c', 'd']; // ['a', 'b', 'c', 'd']
末尾の削除 pop → slice
pop()
は元の配列を変更しますが、slice()
を使うと新しい配列を作成できます。
ちなみに、slice()
に -1
を渡していますが、これは末尾からさかのぼって数えた順番として解釈されます。
// 破壊的な末尾の削除
const arr2 = ['a', 'b', 'c'];
arr2.pop(); // arr2は ['a', 'b'] になる
// 非破壊的な末尾の削除
const newArr3 = ['a', 'b', 'c'].slice(0, -1); // ['a', 'b']
先頭の削除 shift → slice / 分割代入残余プロパティ
先頭の要素を変更する場合も shift()
は破壊的ですが、slice(1)
を使えば非破壊的に処理できます。
少しトリッキーかもしれませんが、分割代入の「残余プロパティ(...
のやつ)」を使って、数直線的に「最初の値だけを捨てて残りを新しい変数に代入する」書き方も可能です。
// 破壊的な先頭の削除
const arr3 = ['a', 'b', 'c'];
arr3.shift(); // arr3は ['b', 'c'] になる
// 非破壊的な先頭の削除
const newArr4 = ['a', 'b', 'c'].slice(1); // ['b', 'c']
const [, ...newArr5] = ['a', 'b', 'c']; // ['b', 'c']
先頭に追加 unshift → toSpliced / スプレッド構文
また、unshift()
を使うと配列の先頭に要素を追加できますが、これも元の配列を変更します。非破壊的に処理するには toSpliced()
やスプレッド構文を利用します。
// 破壊的な先頭への追加
const arr6 = ['a', 'b'];
arr5.unshift('x', 'y'); // arr6は ['x', 'y', 'a', 'b'] になる
// 非破壊的な先頭への追加
const newArr6 = ['a', 'b'].toSpliced(0, 0, 'x', 'y'); // ['x', 'y', 'a', 'b']
const newArr7 = ['x', 'y', ...['a', 'b']]; // ['x', 'y', 'a', 'b']
配列の並び替え sort → toSorted
sort()
や reverse()
は元の配列を変更しますが、ES2023で追加された toSorted()
や toReversed()
を使えば、元の配列を変更せずに並び替えが可能です。
// 破壊的なソート
const arr7 = [2, 300, 10];
arr7.sort((l, r) => l - r); // arr6は [2, 10, 300] になる
// 非破壊的なソート
const sortedArr = [2, 300, 10].toSorted((l, r) => l - r); // [2, 10, 300]
なお、toSorted()
を引数なしで使うと、デフォルトでは文字列として比較されるため、意図しない並び順になることがあります。
const defaultSort = [2, 300, 10].toSorted(); // ['10', '2', '300']
逆順 reverese → toReversed
同様に、配列の順番を逆にする reverse()
も toReversed()
を使うことで非破壊的に処理できます。
// 破壊的な逆順変換
const arr8 = ['a', 'b', 'c'];
arr8.reverse(); // arr7は ['c', 'b', 'a'] になる
// 非破壊的な逆順変換
const reversedArr = ['a', 'b', 'c'].toReversed(); // ['c', 'b', 'a']
まとめ
この記事は、MDN の以下の記事の一覧表を参考に、わかりやすくコード例を付け加えたものです。
「これぐらいのこと、最新情報を追ってる人なら当たり前に把握してるやろ」と思っていましたが、「ケース→使えるメソッド」の逆引き形式で一覧できるようまとめている記事が、軽く探しても見当たらなかったので、軽くまとめてみました。
あと、reduce
, Object.groupBy
, find
, some
, every
のような、「変更というより集計」というタイプのメソッド、Object.entried
のような「変更というより変換」というタイプのメソッドはこの記事では省略しました。