【 React 】更新物件類型的 state

Jamie Lo
9 min readFeb 20, 2023

--

Photo by Volodymyr Hryshchenko on Unsplash

本篇筆記摘錄自官方文件 Updating Objects in State

JavaScript 裡任何類型的值都可以使用狀態 ( state ) 來處理,包含了物件。

但我們不能直接改變狀態中的物件,如果想要更新一個物件,需要創造一個新的物件,或是複製原本存在的物件,使用複製的物件設置狀態。

What’s a mutation?

你可以將 JavaScript 任何種類的值儲存在狀態裡。

const [x, setX] = useState(0);

根據官方文件的順序,到目前為止已經使用過狀態來儲存 number、string、boolean。

這些 JavaScript 的值為 “immutable“,意思為不可變的,或是唯讀的。
我們可以透過觸發重新渲染來取代值:

setX(5);

狀態裡的x0轉變成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.cityobj2.artwork.city,這是因為obj3.artworkobj2.artworkobj1為同一個物件。

如果我們只是單純用巢狀來看待它們,將會很容易忽略這件事。

正確來說,它們只是指向彼此屬性,但是是分開的物件。

Write concise update logic with Immer

如果狀態處於深度的巢狀結構,我們可能會想要將它們分開 。
但如果不想改變狀態結構,可以選擇捷徑將巢狀展開。

Immer 是一個熱門的函式庫,它讓我們可以使用 mutate 的寫法,並且為我們產出複製結果:

updatePerson(draft => {
draft.artwork.city = 'Lagos';
});

【 DEEP DIVE 】

Immer 提供的draft是一個類別叫做 Proxy 的特別物件,它會紀錄你做了什麼,這也是為什麼你可以自由的 mutate,Immer 會辨識出draft中改變的部分,並且製造出一個包含編輯內容的新物件。

Immer 的使用方法:

  1. 執行 npm install use-immer
  2. import { useState } from 'react'
    改為import { useImmer } from 'use-immer'

以下為修改成 Immer 寫法的範例:

從範例中可以看出事件處理的程式碼變得更為簡潔,useStateuseImmer可以在同個元件內搭配使用

Immer 是讓程式碼變得簡潔的好方法,尤其是狀態裡的物件不只一層時,使用 Immer,將不會發生像前面範例,複製物件會影響其他物件問題。

--

--

Jamie Lo

正在往前端這個知識量爆炸的黑洞前行,內容多為平時的筆記整理,希望也能幫助到同樣在這條道路上前進的人💪