更新 State 內的 Object

State 可以儲存任何 Javascript 的值,包含 object ,但你不應該直接在 React 的 state 中修改 object ;取而代之,當想要更新一個 object 時,你需要建立一個新的(或是複製既有的),接著使用副本設定 state 。

You will learn

  • 如何在 React state 中正確地更新 object
  • 如何更新一個巢狀的 object 且不改變它
  • 什麼是 immutability 與如何避免破壞它
  • 如何使用 Immer 減少 object 的重複複製

什麼是 Mutation?

你可以在 state 中儲存任何一種 Javascript 的值。

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

至今你已經使用數字、字串以及布林值,這些 Javascript 的值是「不可改變的」,代表無法改變或「只能讀取」。你可以觸發一個 re-render 以更新值:

setX(5);

x 的 state 從 0 被改成 5 ,但未改變數字 0 本身;改變任何內建的 primitive value ,像是 Javascript 中的數字、字串與布林值是不可能的。

現在想像 state 中的 object :

const [position, setPosition] = useState({ x: 0, y: 0 });

在技術上可以改變 object 本身的內容,稱之為 mutation

position.x = 5;

然而,雖然技術上在 React state 中的 object 是可改變的,但你仍需將它們當成是不可改變的——像數字、布林值與字串;比起改變它們,你應該總是更新它們。

將 State 視為只能讀取

換句話說,你應該把任何放到 state 內的 Javascript object 當成只能讀取

此案例是在 state 內儲存一個 object ,以顯示目前游標的位置;紅點應該會在你觸摸或超過預覽區域時移動,但點停留在開始的位置:

import { useState } from 'react';
export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        position.x = e.clientX;
        position.y = e.clientY;
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

問題出於這段程式碼。

onPointerMove={e => {
position.x = e.clientX;
position.y = e.clientY;
}}

該程式修改 object 在上一次 render 被分配到的 position ,但沒有使用 state setting 函數, React 不知道 object 已經被改變,因此 React 沒有任何回應,就像已經吃飽飯後還嘗試修改點餐內容。儘管改變的 state 可以在有些情況下運作,但我們不建議,你應該將在 render 中存取的 state 值視為只能讀取。

為了在此情境中實際觸發一個 re-render建立一個新的 object 並將它傳給一個 state setting 函數

onPointerMove={e => {
setPosition({
x: e.clientX,
y: e.clientY
});
}}

使用 setPosition ,你告訴 React :

  • 使用此新物件更新 position
  • 並再次 render 該 component

當你的游標碰到或放過預覽區域時,注意紅點現在如何跟著它:

import { useState } from 'react';
export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0
  });
  return (
    <div
      onPointerMove={e => {
        setPosition({
          x: e.clientX,
          y: e.clientY
        });
      }}
      style={{
        position: 'relative',
        width: '100vw',
        height: '100vh',
      }}>
      <div style={{
        position: 'absolute',
        backgroundColor: 'red',
        borderRadius: '50%',
        transform: `translate(${position.x}px, ${position.y}px)`,
        left: -10,
        top: -10,
        width: 20,
        height: 20,
      }} />
    </div>
  );
}

Deep Dive

Local mutation 是可以的

類似這樣的程式碼會有問題,因為它修改 state 內的既有 object :

position.x = e.clientX;
position.y = e.clientY;

但像這樣的程式是完全沒有問題的,因為你正在改變一個剛建立的新 object :

const nextPosition = {};
nextPosition.x = e.clientX;
nextPosition.y = e.clientY;
setPosition(nextPosition);

事實上,它完全等同於這樣編寫:

setPosition({
x: e.clientX,
y: e.clientY
});

Mutation 只在改變既有的 object 時才會是個問題。改變一個剛建立的是可以的,因為還未有其他程式碼參考它,改變它不會意外影響到某些依賴它的東西。這稱為「 local mutation 」,你甚至可以在 render 期間執行局部的改變,非常方便,而且完全是可以的!

使用 Spread 語法複製 Object

在前一個案例中, position object 總是由目前的游標位置重新建立,但你經常會希望新建立的 object 包含既有的資料;例如,或許你只想在表單內更新一個欄位,但仍保留其他所有欄位先前的值。

以下的 input 欄位無法運作,因為 onChange 處理器改變 state :

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    person.firstName = e.target.value;
  }

  function handleLastNameChange(e) {
    person.lastName = e.target.value;
  }

  function handleEmailChange(e) {
    person.email = e.target.value;
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

例如,這一行改變上次 render 的 state :

person.firstName = e.target.value;

取得預想動作最可靠的方式是建立一個新 object ,並將它傳給 setPerson ,但在此,你還想將既有的資料複製到其中,因為只有一個欄位被改變:

setPerson({
firstName: e.target.value, // New first name from the input
lastName: person.lastName,
email: person.email
});

你可以使用 object spread 語法的 ... ,如此一來,你就不需要個別複製每個 property。

setPerson({
...person, // 複製舊的欄位
firstName: e.target.value // 但覆寫它
});

現在表單運作了!

留意你如何沒有為每個 input 欄位分別宣告 state 變數。對大型表單而言,將所有資料組織在同一個 object 是很方便的——只要你正確地更新!

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleFirstNameChange(e) {
    setPerson({
      ...person,
      firstName: e.target.value
    });
  }

  function handleLastNameChange(e) {
    setPerson({
      ...person,
      lastName: e.target.value
    });
  }

  function handleEmailChange(e) {
    setPerson({
      ...person,
      email: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          value={person.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={person.lastName}
          onChange={handleLastNameChange}
        />
      </label>
      <label>
        Email:
        <input
          value={person.email}
          onChange={handleEmailChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

注意 ... spread 語法是「淺的」——它只複製一層深的東西;這使它快速,卻也代表如果你想更新一個巢狀的 property ,你會需要多次使用。

Deep Dive

為複數欄位使用單一事件處理器

你也可以在 object 定義中使用中括號 [] ,為一個 property 指定動態名稱。以下是一些範例,但使用單一事件處理器取代三個:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    firstName: 'Barbara',
    lastName: 'Hepworth',
    email: 'bhepworth@sculpture.com'
  });

  function handleChange(e) {
    setPerson({
      ...person,
      [e.target.name]: e.target.value
    });
  }

  return (
    <>
      <label>
        First name:
        <input
          name="firstName"
          value={person.firstName}
          onChange={handleChange}
        />
      </label>
      <label>
        Last name:
        <input
          name="lastName"
          value={person.lastName}
          onChange={handleChange}
        />
      </label>
      <label>
        Email:
        <input
          name="email"
          value={person.email}
          onChange={handleChange}
        />
      </label>
      <p>
        {person.firstName}{' '}
        {person.lastName}{' '}
        ({person.email})
      </p>
    </>
  );
}

這裡, e.target.name 參考賦予 <input> DOM 元素的 name property 。

更新一個巢狀的 Object

想像一個像這樣的巢狀 object 結構:

const [person, setPerson] = useState({
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
});

如果你想更新 person.artwork.city ,要怎麼改變它是很清楚的:

person.artwork.city = 'New Delhi';

但在 React 中,你將 state 當成是不可變的!為了改變 city ,首先你需要建立新的 artwork object (使用先前的資料預先填入),接著再建立可以指向新 artwork 的新 person object :

const nextArtwork = { ...person.artwork, city: 'New Delhi' };
const nextPerson = { ...person, artwork: nextArtwork };
setPerson(nextPerson);

或者,寫成單一函數呼叫:

setPerson({
...person, // 複製其他欄位
artwork: { // 但更新藝術品
...person.artwork, // 使用相同的
city: 'New Delhi' // 但是在 New Delhi!
}
});

這會有點囉唆,但它可在許多情況下運作:

import { useState } from 'react';

export default function Form() {
  const [person, setPerson] = useState({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    setPerson({
      ...person,
      name: e.target.value
    });
  }

  function handleTitleChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        title: e.target.value
      }
    });
  }

  function handleCityChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        city: e.target.value
      }
    });
  }

  function handleImageChange(e) {
    setPerson({
      ...person,
      artwork: {
        ...person.artwork,
        image: e.target.value
      }
    });
  }

  return (
    <>
      <label>
        Name:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Title:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        City:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Image:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img 
        src={person.artwork.image} 
        alt={person.artwork.title}
      />
    </>
  );
}

Deep Dive

Objects 並非真的是巢狀的

一個 object 會像這樣「套疊」出現在程式中:

let obj = {
name: 'Niki de Saint Phalle',
artwork: {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
}
};

然而,「套疊」是不精確思考 object 行為的方法。當程式執行時,並沒有所謂「套疊的」 object ,你實際上看到的是兩個不同的 object :

let obj1 = {
title: 'Blue Nana',
city: 'Hamburg',
image: 'https://i.imgur.com/Sd1AgUOm.jpg',
};

let obj2 = {
name: 'Niki de Saint Phalle',
artwork: obj1
};

ob1 object 並非在 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
};

如果你要改變 obj3.artwork.city ,它會影響 obj.artwork.cityobj1.city 兩者,這是因為 obj3.artworkobj2.artworkobj1 是相同的物件。當你將物件視為「套疊」時,很難觀察看到這一點;取而代之,它們是分散的 object ,並透過 property 「指向」彼此。

使用 Immer 編寫簡潔的更新邏輯

如果你的 state 套疊得很深,你可能會考慮將它攤平;但假如不想改變 state 的結構,你也許會偏好一個展開套疊的捷徑。 Immer 是一個知名的函數庫,讓你方便編寫卻又可以使用改變的語法,且為你產生一份副本;使用 Immer ,你所編寫的程式看起來像「打破規則」與改變一個 object :

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

但不像普通的改變,它不會覆蓋過去的 state !

Deep Dive

Immer 如何執行?

Immer 提供的 draft 是 obkect 的特別型態,稱為 Proxy ,「紀錄」任何你做的事情。這是為什麼你可以依照喜好自由地改變它!其原理是 Immer 指出哪些部分的 draft 已經改變,藉此產生一個新 object ,且包含你的編輯。

嘗試使用 Immer :

  1. 執行 npm install use-immer 將 Immer 加入 dependency
  2. import { useState } from 'react' 更新成 import { useImmer } from 'use-immer'

這是將上述案例轉換成 Immer :

import { useImmer } from 'use-immer';

export default function Form() {
  const [person, updatePerson] = useImmer({
    name: 'Niki de Saint Phalle',
    artwork: {
      title: 'Blue Nana',
      city: 'Hamburg',
      image: 'https://i.imgur.com/Sd1AgUOm.jpg',
    }
  });

  function handleNameChange(e) {
    updatePerson(draft => {
      draft.name = e.target.value;
    });
  }

  function handleTitleChange(e) {
    updatePerson(draft => {
      draft.artwork.title = e.target.value;
    });
  }

  function handleCityChange(e) {
    updatePerson(draft => {
      draft.artwork.city = e.target.value;
    });
  }

  function handleImageChange(e) {
    updatePerson(draft => {
      draft.artwork.image = e.target.value;
    });
  }

  return (
    <>
      <label>
        Name:
        <input
          value={person.name}
          onChange={handleNameChange}
        />
      </label>
      <label>
        Title:
        <input
          value={person.artwork.title}
          onChange={handleTitleChange}
        />
      </label>
      <label>
        City:
        <input
          value={person.artwork.city}
          onChange={handleCityChange}
        />
      </label>
      <label>
        Image:
        <input
          value={person.artwork.image}
          onChange={handleImageChange}
        />
      </label>
      <p>
        <i>{person.artwork.title}</i>
        {' by '}
        {person.name}
        <br />
        (located in {person.artwork.city})
      </p>
      <img 
        src={person.artwork.image} 
        alt={person.artwork.title}
      />
    </>
  );
}

注意事件處理器如何變得更加簡潔。你可以依照喜好在單一 component 內混合及搭配 useStateuseImmer 。 Immer 是保持更新處理器簡潔的好方法,特別是如果在 state 中有套疊、以及複製 object 導致程式碼重複的時候。

Deep Dive

有幾個原因:

  • 除錯: 如果使用 console.log 且不改變 state ,過去的紀錄不會被更多後來的 state 改變所破壞,因此你可以清楚地看見 state 在每次 render 如何改變。
  • 最佳化: 如果上一個 props 或 state 與下一個相同,通常 React 的最佳化策略仰賴省略執行。如果從未變異 state ,確認是否有任何修改會非常快速。如果 prevObj === obj ,可以確定內部沒有東西改變。
  • 新功能: 我們打造的 React 新功能仰賴於 state 被視為快照;如果改變過去的 state 版本,可能會阻止你使用新功能
  • 請求變更: 有些應用程式的功能像是取消/重做、顯示歷史修改、或讓使用者重新設定稍早之前的值,在沒有東西被改變時很容易,這是因為可以在記憶體中保留過去的 state ,並在適當時機重新使用它們;如果一開始就使用一個改變的方式,後續難以加上這些功能
  • 更簡單的實作: 因為 React 不仰賴改變,它不需要對你的 object 做任何特別的事情;不需要劫持它們的 property 、總是把它們包進 proxy 內、或其他在初始化時執行許多「反應的」解決辦法。這也是為什麼 React 讓你可以將任何 object 放入 state 中——不管多大——沒有多餘的效能與正確性的陷阱。

在實務中,你可以經常在 React 中改變 state 而不會出錯,但我們強烈建議你不要這樣做,以便你可以使用 React 新開發的功能。未來的貢獻者、甚至未來的你都會非常感謝你!

Recap

  • 將 React 中的所有 state 視為不可改變的
  • 當你在 state 中儲存 object 時,改變它們不會觸發 render ,且會改變 state 在上次 render 的「快照」
  • 比起改變一個 object ,為它建立一個版本,並藉由為它設定 state 觸發 re-render
  • 你可以使用 {...obj, something: 'newValue'} 的 object spread 語法建立一個 object 的副本
  • Spread 語法是淺的:它只會複製一層
  • 為了更新巢狀的 object ,你需要在從更新的地方一路向上建立副本
  • 使用 Immer 減少重複複製的程式,

Challenge 1 of 3:
修改不正確的 State 更新

該表單存在一些錯誤。點擊幾次按鈕增加分數,留意它未增加;接著編輯名字,注意分數突然因為你的改變而追趕上來;最後編輯姓氏時,注意分數完全消失。

你的工作是修改全部的錯誤,當你修改它們時,解釋它們為何發生。

import { useState } from 'react';

export default function Scoreboard() {
  const [player, setPlayer] = useState({
    firstName: 'Ranjani',
    lastName: 'Shettar',
    score: 10,
  });

  function handlePlusClick() {
    player.score++;
  }

  function handleFirstNameChange(e) {
    setPlayer({
      ...player,
      firstName: e.target.value,
    });
  }

  function handleLastNameChange(e) {
    setPlayer({
      lastName: e.target.value
    });
  }

  return (
    <>
      <label>
        Score: <b>{player.score}</b>
        {' '}
        <button onClick={handlePlusClick}>
          +1
        </button>
      </label>
      <label>
        First name:
        <input
          value={player.firstName}
          onChange={handleFirstNameChange}
        />
      </label>
      <label>
        Last name:
        <input
          value={player.lastName}
          onChange={handleLastNameChange}
        />
      </label>
    </>
  );
}