探索 React Hook——useState(),掌握元件狀態管理的核心

zxlee-avatar-img
發佈於React
更新 發佈閱讀 18 分鐘

一個應用程式最重要的功能,莫過於狀態管理。

在 React 內建的 Hook 中,也提供了兩種管理狀態的工具—— useState()useReducer(),而當中較為常用的,就是本文將要介紹的 useState()

何謂「狀態 (State)」?

正如 React 名稱所示,因應使用者的互動,React 元件也經常需要更新新的介面。比如說,

  • 點擊「購買」按鈕,商品應該加入購物車
  • 在搜尋框鍵入關鍵字,應該呈現篩選結果

為了做到這些事,React 元件需要「記住」某些當前的資料,並根據事件更新畫面。這種會隨事件改變、且影響畫面呈現的資料,在 React 中稱為狀態 (state)

React Hooks 使用規範

在介紹 useState() 之前,先來瞭解 React Hooks 的基本規範:

1. 只能用在函式元件與自訂 Hooks 中

Hooks 僅能用於函式元件 (Function Component) 自訂 Hooks 之中,無法使用於類別元件 (Class Component)。

在使用 Next.js 時,只有 Client Component 才能使用像 useState 這類需要狀態的 Hooks。

2. Hooks 必須依固定順序執行

每次元件渲染時,Hooks 都必須依照相同順序被呼叫。因此,Hooks 只能放在元件最上層,不能寫在:

  • 條件語句,例如:if / else
  • 迴圈,例如:forwhile
  • 一般函式中

這是 React 能正確對應狀態位置的前提。

useState() 基本用法

useState() 是 React 初始化狀態的基本方式:

首先,從 React 匯入 useState,並在函式元件中使用它。它接受一個參數,作為狀態的初始值:

import { useState } from "react"; // 匯入

const initialState = "初始狀態" 

function App() {
useState(initialState);

return ...;
};

export default App;

useState() 會回傳一個長度為 2 的陣列,陣列的值依序是「當前的狀態」、「更新狀態的方法」(setter):

const stateArray = useState(initialState)
const state = stateArray[0];
const setState = stateArray[1];

不過這種寫法過於冗長,實務上幾乎不會這樣使用,在使用 useState() 時,通常會以陣列解構的形式出現:

const [state, setState] = useState(initialState);

各部分的意義如下:

  • useState:定義元件狀態的 Hook
  • initialState:狀態的起始值
  • state:目前狀態的值
  • setState:更新狀態的方法(會觸發重新渲染)

用 useState 實作計數器元件

先從一個靜態的元件開始:

function Counter() {
return (
<>
<button>-</button>
<div>0</div>
<button>+</button>
</>
);
};

export default Counter;

接著加入狀態,讓數字可以動態改變。

1. 加入狀態並顯示

import { useState } from "react"; // 1. 匯入 useState

function Counter() {
const [count, setCount] = useState(0)
// 2. 透過 useState 取得 count 的狀態及設定方法,
// 並以 0 作為初始值

return (
<>
<button>-</button>
{/* 3. 👇 改用 count 來呈現當前數字 */}
<div>{ count }</div>
<button>+</button>
</>
);
};

export default Counter;

2. 加上按鈕功能

// 減法功能
function decreaseCount() {
setCount(count - 1)
};

// 加法功能​
function increaseCount() {
setCount(count + 1)
};

並分別綁定到對應按鈕:


// 減法按鈕​
<button onClick={decreaseCount}>-</button>

// 加法按鈕​
<button onClick={increaseCount}>+</button>

然後,將以上邏輯放入整個元件:

import { useState } from "react";

function Counter() {
const [count, setCount] = useState(0)

function decreaseCount(){
setCount(count - 1);
};

function increaseCount(){
setCount(count + 1);
};

return (
<>
<button onClick={decreaseCount}>-</button>
<div>{ count }</div>
<button onClick={increaseCount}>+</button>
</>
);
};

export default Counter;

至此,一個最基本的計數器就完成了 🎉

使用函式設定初始狀態 (Initializer)

除了直接傳值之外,也可以傳入函式來設定初始狀態:

function initializeCount() {
console.log("count initialized");

return 0;
} 

function Counter() {
const [count, setCount] = useState(initializeCount);

//...
}

這種函式稱為 initializer function,特點是:

  • 必須是無參數的純函式
  • 只會在初始渲染時執行一次
  • 適合用於需要計算或轉換的初始值

但值得注意的是,傳入 useState 時並不需要呼叫,否則每次渲染都會執行:

// ❌ 每次渲染都會執行
useState(initializeCount());

為什麼不能用一般變數取代狀態?

從上方例子來看,React 的「狀態」似乎與原生 JS 的變數相去不遠。那麼,如果改用變數來存取 count,會發生什麼事?(僅用加法為例)

function Counter() {
let count = 0; // 改用一般變數定義

// 加法邏輯
function increaseCount() {
count = count + 1;
console.log(count);
}

return (
<>
<div>{ count }</div>
<button onClick={increaseCount}>+</button>
</>
);
}

export default Counter;

按下按鈕後,畫面並不會更新,但如果透過 console,會發現 count 的值確是會遞加的。

既然如此,在刻意用一般變數定義 count 的前提下,使用其他方式觸發重新渲染,讓我們看看這樣能否達成畫面的更新?為此,我們將在程式碼作出以下調整:

  • 用 useState 設定狀態 renderTimes 來觸發重新渲染
  • 在渲染前後加上 console 觀察 count 的變化
function Counter() {
let count = 0;
const [renderTimes, setRenderTimes] = useState(1);
// 👆 用 useState 給的 setState 方法作為觸發元件重新渲染的方式

console.log("渲染次數:", renderTimes);
console.log("計算前的數字:", count);

function increaseCount() {
count = count + 1;
setRenderTimes(renderTimes + 1); // 觸發元件重新渲染
console.log("計算後的數字:", count);
}

return (
<>
<div>{ count }</div>
<button onClick={increaseCount}>+</button>
</>
);
}

export default Counter;

這次,畫面依然沒有更新,查看 console,會是這樣的情形:

渲染次數:1
計算前的數字:0
計算後的數字:1 

渲染次數:2
計算前的數字:0
計算後的數字:1

藉由以上兩種實驗,除了應證上述狀態可以跨渲染保留之外,對於一般變數,我們可以得到兩個結論:

  • 一般變數的改變 → 不會觸發重新渲染
  • 元件重新渲染後 → 一般變數會重新計算

使用一般變數的狀況:狀態的衍生值 (derived value)

當然,並非一昧地使用狀態定義變數就好,由於 React 的運作機制,當使用 setState 時,會發生以下流程:

  1. 標記狀態發生改變的元件
  2. 重新執行該函式元件
  3. 重新計算 JSX
  4. 比對 Virtual DOM
  5. 視情況更新真實 DOM

一次重新渲染是昂貴的,相對的,一般變數不會被 React 追蹤、不會觸發重新渲染;若以效能角度來思考,如果需要的資料是狀態的衍生值 (derived value) 的話,使用一般變數制定會是更好的實踐:

function Counter() {
const [count, setCount] = useState(0);
const doubleCount = count * 2;
// doubleCount 是由 count 衍生出來的值,用一般變數定義即可

// ...
}

重新渲染不是在更新狀態後同步執行的

假如現在希望按下增加按鈕時,讓當前數字加 2,而連續呼叫 setCount(count + 1) 兩次:

function increaseCount(){
setCount(count + 1);
setCount(count + 1);
};

即使如此,畫面只會加 1。

為了避免在單一事件處理中反覆渲染元件多次,因此 React 是在事件處理結束後,對狀態進行批次處理 (batching)。

可以想像每次渲染都是一張固定的快照,當批次處理狀態時,React 才會比對差異,決定下次渲染的部分。因此,在事件處理中直接讀取狀態時,取得的都會是「當次渲染的快照」,而不是即將更新後的值。

因此在處理 increaseCount 時,在函式所讀取到的 count,都會是當前的狀態。

使用 updater function 更新狀態

若更新狀態時需要依賴「上一個即將更新的狀態」,可以使用 updater function:

setCount(c => c + 1);

Updater 作為 setState 的回調函式,需以上個待更新的狀態作為參數,回傳處理過後的結果。

例如:

const [count, setCount] = useState(5);

function increaseCount(){
setCount(count + 1);
setCount(c => c + 1);
};

此時第二個 setCount 會基於「上一個 setCount 的待更新狀態」,也就是 count + 1 的結果,便能夠在下次渲染看到正確累加的結果。

物件與陣列的狀態更新

物件及陣列在 JavaScript 中都屬於可變資料,可以透過指派或一些原生方法來直接改變其內容;但 React 處理狀態的方式,其實比較接近不可變資料的處理手法,也就是以新的狀態覆寫舊的狀態。

比如說,我們透過 useState 來定義 user 的資料:

const [user, setUser] = useState({
id: 7,
name: "Leon",
age: 18,
});

❌ 錯誤做法:

user.age = 19;        
setUser(user); // 未建立新參考,不會觸發渲染
 
setUser({ age: 19 }); // 下次渲染將以 { age: 19 } 覆蓋整個物件

✅ 正確做法:

setUser({
...user, // 1. 複製原本的物件
age: 19; // 2. 覆寫更新的鍵值
});

陣列資料也同理,比如說:

const [nums, setNums] = useState([1, 2, 3]);

❌ 錯誤做法:

nums.push(4); 
setNums(nums); // 未建立新參考

✅ 正確做法:

setNums([...nums, 4]);

透過 setState 設定物件資料(物件、陣列),基本上要注意兩大點:

  1. 建立新的參考,React 才能正確判斷狀態改變。
  2. 傳入要覆寫舊狀態的完整物件。

透過 key 重置狀態

最後,補充一個應該算是相對冷門的知識,我們可以透過變更元件的 key 值,來重置該元件的狀態。

比如說,

function App() {
const [version, setVersion] = useState(0);

function handleReset() {
setVersion((v) => v + 1);
}

return (
<>
<div className="flex flex-column">
<Counter key={version} />
<button onClick={handleReset}>Reset</button>
</div>
</>
);
}

CodePen 範例:

React 狀態是屬於元件實例的,當渲染發生時,React 會嘗試重用舊的元件實例來保留狀態。至於如何判斷是否屬於同一個元件,大致會根據以下順序:

  1. 元件名稱(<Counter /> vs. <User />
  2. key 值

其中一個不同,就會被視為不同元件,當被視為不同的元件,React 就會創造新的實例,卸載舊的元件,讓新的元件掛載,狀態也會被初始化。

換句話說,key 值像是身分證,協助 React 辨識元件的身份,這也是為什麼當渲染列表時,會需要使用不同的 key。當列表項目發生變動,或是某一項目的狀態改變時,React 能夠保留未變動的元件狀態,進而減少效能的浪費。

結論

  • 狀態更新能夠在跨渲染保留;一般變數則無法
  • 狀態更新不會同步反映在當次渲染中
  • 當狀態的初始值需要昂貴計算時,可用 initializer 初始化
  • 狀態的衍生值建議透過一般變數制定
  • 變更狀態需要依賴前一個狀態時,請使用 updater function
  • 物件與陣列狀態更新時,務必建立新的參考
  • 改變 key 值可以用來重置元件的狀態

參考



留言
avatar-img
肝 code 人生
1會員
8內容數
2024 年 7 月開始的「肝 code 人生」,2025 年 1 月撰寫第一篇程式筆記