本篇筆記摘錄自官方文件 Updating Objects in State
JavaScript 裡任何類型的值都可以使用狀態 ( state ) 來處理,包含了物件。
但我們不能直接改變狀態中的物件,如果想要更新一個物件,需要創造一個新的物件,或是複製原本存在的物件,使用複製的物件設置狀態。
What’s a mutation?
你可以將 JavaScript 任何種類的值儲存在狀態裡。
const [x, setX] = useState(0);
根據官方文件的順序,到目前為止已經使用過狀態來儲存 number、string、boolean。
這些 JavaScript 的值為 “immutable“,意思為不可變的,或是唯讀的。
我們可以透過觸發重新渲染來取代值:
setX(5);
狀態裡的x
從0
轉變成5
,但是數字0
本身並沒有被改變。
在 JavaScript 中,改變原始 ( Primitive ) 型別的值是不可能的。
接下來探討狀態內容為物件時的情形:
const [position, setPosition] = useState({ x: 0, y: 0 });
技術上來說,改變物件本身的內容是有可能的,這被稱之為 “mutation”:
( mutate 有突變的意思,但翻起來非常怪,因此本篇以原文呈現。 )
position.x = 5;
但是我們依舊必須視它們為 immutable ( 不可變的 ),必須採用取代值的方式,而不是直接改變它們。
Treat state as read-only
換句話說,我們應該要將放進狀態的物件視為唯讀 ( read-only )。
下方範例的預期為:
當我們在當我們在視窗裡滑動游標時,紅色的點會跟著游標移動。
但是範例中的紅色點,始終維持在初始位置。
( 將左側拉桿向右移動,可以看見程式碼的部分,拉開後點擊左上角三條線漢堡圖示可以看見完整檔案,右邊則為輸出結果。 )
問題出在這段程式碼:
onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}
此方法在渲染前改變物件對position
的指派,但在沒有使用狀態設置函式的情形下,React 是不會知道物件被改變的,所以 React 沒有做任何事來回應,這種感覺就像在已經吃完餐點之後,才說要改變訂單。
雖然 mutate 狀態在某些案例中是可以運行的,但 React 官方並不推薦。
如果要達成預期功能,需要創建一個新的物件,並將它傳入狀態設置函式:
onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}
透過setPosition
告訴 React:
- 在新物件內替換
position
的值。 - 再次渲染這個元件。
經過修改後,已達成預期的功能:
【 DEEP DIVE 】
下方的程式碼會發生錯誤是由於直接修改物件狀態:
position.x = e.clientX;
position.y = e.clientY;
但是修改新建立的物件是沒有關係的:
const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);
上方的寫法與下方是完全等相同的:
setPosition({
x: e.clientX,
y: e.clientY
});
這是因為新建立的物件會有新的 reference,尚未有其他物件參照同一個 reference,不需擔心因更動內容而影響其他物件。
這樣的做法被稱為 “local mutation”。
Copying objects with the spread syntax
在前面的範例裡,每當游標移動時,就會創建新的position
物件。
但是有些時候,我們會希望在新物件內包含一些原有的資料。
例如:更新表格中的其中一格資料,但是其他區塊保有原本的值。
以下方的範例來說,之所有沒有辦法在輸入框輸入新內容,是因為onChange
處理器 mutate 了狀態:
原因出自下面這段程式碼:
person.firstName = e.target.value;
依照前面的範例,我們應該要創建一個新物件,並且將它傳setPerson
。
但在這個範例裡,我們只要更動其中一個部分,其他則是複製原本的資料:
setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});
我們可以使用展開運算符 ( spread operator ) 語法來複製物件內的資訊,這樣就不用分開複製每一個屬性:
setPerson({
...person, // Copy the old fields
firstName: e.target.value // But override this one
});
修正後的範例已可以正常運作:
注意:展開運算符是一種淺拷貝,它一次只能拷貝一個層級的物件,換句話說,如果你的物件裡面還有其他的物件,則需要進行多次操作。
【 DEEP DIVE 】
接下來的這個範例與上面的相同,只是做了優化。
原本有三個事件處理器,現在整合為一個,使用[]
的方式來放入指定的屬性,動態地獲取資訊:
Updating a nested object
現有一個物件如下:
const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});
如果我們想要更新 city 的部分,以下程式碼顯然是 mutation 的類型:
person.artwork.city = 'New Delhi';
前面有提到,在 React 中,我們要將狀態視為不可變的。
為了改變city
,需先創建一個新的artwork
物件與一個新的person
物件:
const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);
或是直接寫進setPerson
:
setPerson({
...person, // Copy other fields
artwork: { // but replace the artwork
...person.artwork, // with the same one
city: 'New Delhi' // but in New Delhi!
}
});
雖然程式碼看起來有點囉唆,但大部分的情形下都可以運作得非常好:
【 DEEP DIVE 】
下方的物件程式碼看起來像是巢狀結構:
let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};
但用巢狀來形容物件是不準確的,它們本質上是完全不同的兩個物件:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
obj1
並非在obj2
“裡面”。
以下方的例子來說,obj3
同樣能指向obj1
:
let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};
let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};
let obj3 = {
name: 'Copycat',
artwork: obj1
};
如果我們 mutateobj3.artwork.city
,將會同時影響到obj1.city
與obj2.artwork.city
,這是因為obj3.artwork
、obj2.artwork
、obj1
為同一個物件。
如果我們只是單純用巢狀來看待它們,將會很容易忽略這件事。
正確來說,它們只是指向彼此屬性,但是是分開的物件。
Write concise update logic with Immer
如果狀態處於深度的巢狀結構,我們可能會想要將它們分開 。
但如果不想改變狀態結構,可以選擇捷徑將巢狀展開。
Immer 是一個熱門的函式庫,它讓我們可以使用 mutate 的寫法,並且為我們產出複製結果:
updatePerson(draft => {
draft.artwork.city = 'Lagos';
});
【 DEEP DIVE 】
Immer 提供的draft
是一個類別叫做 Proxy 的特別物件,它會紀錄你做了什麼,這也是為什麼你可以自由的 mutate,Immer 會辨識出draft
中改變的部分,並且製造出一個包含編輯內容的新物件。
Immer 的使用方法:
- 執行
npm install use-immer
- 將
import { useState } from 'react'
改為import { useImmer } from 'use-immer'
以下為修改成 Immer 寫法的範例:
從範例中可以看出事件處理的程式碼變得更為簡潔,useState
與 useImmer
可以在同個元件內搭配使用。
Immer 是讓程式碼變得簡潔的好方法,尤其是狀態裡的物件不只一層時,使用 Immer,將不會發生像前面範例,複製物件會影響其他物件問題。