iT邦幫忙

2026 iThome 鐵人賽

DAY 9
0
自我挑戰組

React 入門到實作與除錯|30 天哩ㄟ刻系列 第 9

Day 9 : 念念不忘,用 state 讓元件記住資料

  • 分享至 

  • xImage
  •  

想念你的笑,想念你的外套

今天目標

  1. 比較一般變數與狀態
  2. 認識狀態更新、Hook、useState
  3. 製作飲料計數器
  4. 控制飲品介紹的開合
  5. 組合與測試

知道怎麼處理事件後,再加上狀態記憶,就能夠做許多有趣的互動。

這次我們不沿用 day-1-hello-react,建一個新的專案處理吧。

專案我們就命名為 drink-order,一樣使用 React Router 建立。

npx create-react-router@latest src/drink-order
cd src/drink-order
npm install
npm run dev

專案主要是飲料點餐的情境,這次會建立兩個畫面,分別是

  • 變數觀察頁:比較 Console 的值與畫面文字
  • 飲料點餐首頁:紅茶與綠茶各自選擇杯數、展開介紹

比較一般變數與狀態

先從點餐時最基本的需求開始吧:記錄選了幾杯紅茶。畫面一開始顯示「已選 0 杯」,每按一次「加一杯」,杯數就增加,畫面也更新的數字。

最直覺的做法,就是用一般變數記錄,在點擊時將變數加一,來看變數的值改變,React 畫面是否會跟著更新嗎。

一般變數的做法

首先建立紅茶杯數的元件命名為 VariableCounter,在元件函式裡用 quantity 記錄杯數,初始值設為 0,並準備「加一杯」的處理函式:

// 示意片段:放在元件函式裡。
let quantity = 0;

function handleAdd() {
  quantity += 1;
  console.log("[一般變數] quantity:", quantity);
}

// 示意片段:元件回傳的畫面。
return (
  <section>
    <p>已選 {quantity} 杯</p>
    <button type="button" onClick={handleAdd}>
      加一杯
    </button>
  </section>
);

Console 增加,但畫面沒變

上面的片段組成元件後,在沒有存檔或其他重新渲染的情況下,連按三次,Console 依序出現 123,畫面仍顯示「已選 0 杯」。

這是因為掛在按鈕上的處理函式,能讀寫同一次元件呼叫建立的 quantity,所以 Log 可以繼續增加,但修改這個區域變數,沒有通知 React 重新計算畫面。

一開始回傳的 JSX 已經用 0 描述文字,變數後來改了,既有文字不會自動跟著改,就算之後如果 React 因為其他原因再次呼叫 VariableCounterlet quantity = 0 又會建立新的區域變數,無法把這個值當成跨次渲染的記憶。

這時我們需要兩個功能:
保留資料,以及要求 React 用新資料重新渲染。

就算把 quantity 移到元件外面也沒有完成這兩件事:普通變數的修改仍不會主動通知 React 重新計算畫面,還會變成多個元件可能讀寫的共用資料。

React:When a regular variable isn’t enough

認識狀態更新方式

認識 Hook

從前面的比較發現,元件需要有個地方幫忙保留資料,並在資料更新後重新渲染畫面,這時候就可以用 React 的 Hook

Hook 就是讓函式元件使用 React 功能的特殊函式。Hook 翻譯是「鉤子」,可以理解成「接上 React 功能的入口」。

Hook 相關功能的名稱以 use 開頭,後面接大寫字母,例如 useState。這是命名習慣,state 則是元件記住的資料。**

這次要使用的 useState,就是Hook的其中一種,用來讓元件使用 React 管理的狀態。

React:Meet your first Hook

Hook 與副作用有什麼不同?

看這裡的名詞解釋:

名稱 意思 點餐情境
Hook 使用 React 功能的特殊函式 useState 取得杯數與更新函式
副作用(side effect) 回傳結果以外,對外產生的可觀察影響 把訂單送到伺服器,或把杯數寫入瀏覽器儲存空間
useEffect 用來宣告 Effect 的一個 Hook,讓元件與外部系統同步 要讓瀏覽器分頁標題隨杯數同步,可用 Effect 更新 document.title

副作用可以發生在事件處理函式裡,例如按「送出訂單」後才發送請求。

若需求是隨元件的狀態持續與外部系統同步,才考慮 useEffect 讓 React 在提交畫面更新後執行同步工作。

useEffect 是一個 Hook,而副作用不一定透過 useEffect 執行。
可以看這篇:React:Synchronizing with Effects

交給 React,就不算副作用嗎?

不是說元件函式不能修改呼叫前就存在的外部變數或物件,為什麼現在用Hook就可以了?

這裡是我自己疑惑的地方,Hook 可以把元件內的資料送出去然後更新畫面,副作用也是除了回傳結果,也同時對外產生可觀察的影響,那有什麼差別?

對照元件純粹性的重點:

  • 相同輸入,相同結果。
  • 不修改呼叫前就存在的外部變數或物件。

接著看程式運作流程。

useState() 的 Hook 在事件中提出更新:

事件發生 → 事件處理函式算出新值 → setQuantity 提出更新 → React 重新渲染 → useState 提供新的 state → 計算畫面 → 更新顯示

非純粹元件,在渲染中修改外部資料:

開始渲染 -> 直接修改既有的外部變數(副作用)-> React 用修改後的值計算畫面 -> 更新顯示

從最後的流程來看,可以發現 Hook 是讓 React 在渲染時,取資料再更新。非純粹元件則是會在渲染時,直接修改到既有的外部變數(副作用),導致重複渲染,且可能再次改動資料。

從一般程式設計的定義來看,在事件裡呼叫 setQuantity 會提出狀態更新,影響之後的執行,就是副作用。但 React 要求的是「渲染時保持純粹」,也就是元件保持純粹,而事件處理函式可以執行更新資料等副作用。

看這篇更詳細React:Side effects must run outside of render

回到飲料點餐,當動作是:

  • 渲染時: 呼叫 useState 取得這次的 state,定義 handleAdd,再回傳 JSX。定義 handleAdd 不會執行它裡面的 setQuantity,把它傳給 onClick 也不會。
  • 點擊時: 前一次元件函式已經回傳,React 才因為點擊去執行 handleAdd,由它呼叫 setQuantity 提出更新。這個事件處理函式可以有副作用。
  • 下一次渲染時: useState 提供更新後的 state,元件再用新的輸入計算 JSX。單純多渲染一次,不會因此多執行一次「加一杯」。

元件的渲染契約,是同一組 props、state 與 context,應得到相同的畫面描述,且計算過程不改動外部共用資料。 state 也是這次渲染的輸入,即使資料來自 useState,仍要當成唯讀,不能在渲染時直接改寫其中的物件。

React:Components and Hooks must be pure

認識 useState

接著使用 useState,讓元件記住杯數,並取得更新 state 的函式。先在檔案頂端匯入:

import { useState } from "react";

useState 是執行時要呼叫的函式,所以使用一般匯入,不能寫成 import type。接著把元件裡的 let quantity = 0 改成:

const [quantity, setQuantity] = useState(0);
片段 意思
0 初始值
quantity 這次渲染讀到的飲料杯數
setQuantity 更新函式,把新的值交給 React
[quantity, setQuantity] 依順序接收兩個回傳值。不是物件解構,也不是兩個 state

名稱可以自己取,通常成對命名為 somethingsetSomething

React:useState

const,卻可以改變數字?

const 表示這次函式執行中的 quantity 不能重新賦值。但其實我們沒有直接讓 quantity = 1,而是呼叫 setQuantity(1),讓 React 記住新的值。下次呼叫元件時,useState 再把新的值交給那次渲染的 quantity

同一個元件持續留在畫面上時,重新渲染不會因為又執行 useState(0) 就把值歸零。 因為 0 是初始化時使用的值。

接著直接看實作片段更清楚怎麼使用 useState() 吧。

製作飲料計數器

我們拿這篇最前面一般變數的程式片段來修改,但這次透過 setQuantity 把新的值交給 React:

// 示意片段
function DrinkCounter() {

  const [quantity, setQuantity] = useState(0);

  function handleAdd() {
    setQuantity(quantity + 1);
  }

  function handleClear() {
    setQuantity(0);
  }
  
  // ...略
  // return ...略
}

「加一杯」把目前杯數加一,「清空杯數」則歸零。再把兩個處理函式分別接到按鈕:

// 示意片段:省略元件外層與樣式。
<p>已選 {quantity} 杯</p>
<button type="button" onClick={handleAdd}>
  加一杯
</button>
<button type="button" onClick={handleClear}>
  清空杯數
</button>

稍後會建立 DrinkOrder 點餐頁面,在裡面使用兩次相同的計數器,並透過 name prop 傳入飲料名稱:

<DrinkCounter name="紅茶" />
<DrinkCounter name="綠茶" />

紅茶與綠茶使用相同的計數器元件,各自從 0 杯開始。按紅茶的「加一杯」三次,再按綠茶的一次,預期分別顯示 3 杯與 1 杯。

這裡的 name 是外層元件傳入的飲料名稱,quantity 是各個計數器自己的 state。即使傳入相同名稱,只要是畫面上兩個位置的元件,狀態仍各自保留。

重複使用元件,不代表共用同一份 state。React:State is isolated and private

useState 要放在哪裡?

放在函式元件的最外層呼叫 useState,並在任何可能提早結束函式的 return 之前。

React:Rules of Hooks

以上面的範例來看,對照表格:

位置 合法
DrinkCounter 函式內,事件處理函式與 return 之前 正確,讓元件每次渲染都依固定順序呼叫
handleAdd 錯誤,事件發生時呼叫的是更新函式,不是在那裡新增 state
if、迴圈或 map() 的回呼裡 錯誤,不能讓 useState 的呼叫順序隨條件或筆數改變
DrinkCounter 外面、任何元件函式之外 錯誤,不能用這個方式建立全域 state

操作一次

我們預計流程會是這樣:

第一次顯示:quantity 是 0,畫面顯示「已選 0 杯」
-> 操作「加一杯」
-> handleAdd 呼叫 setQuantity(quantity + 1),這次交出 1
-> React 用更新後的 state 再次呼叫元件
-> 這次 quantity 是 1,畫面顯示「已選 1 杯」

事件處理負責回應操作,setQuantity 提出 state 更新,JSX 再使用 state 描述畫面。

每次點擊只更新一次,會「算出下一個值,再交給更新函式」。

沿用框架預設入口的 StrictMode 開發檢查,在範例中按一次只會增加一次。不要把元件本體可能的額外執行,解讀成點擊被執行兩次。

組合完整程式時,DrinkCounterDrinkDetails 都要在自己的函式最外層呼叫 useState;元件定義則放在 DrinkOrder 外面。將來用清單產生多款飲料的介紹時,也是在 map() 中回傳 <DrinkDetails />,讓每個元件自己宣告 state,而不是直接在 map() 的回呼內呼叫 useState

一個元件可以宣告不只一個 state,只要遵守呼叫位置與順序,也可以用在自訂 Hook 中。

控制介紹的開合

useState 不只可以記數字,也可以使用在布林值上,這裡我們則拿來記住「目前是否展開介紹」。

接著做一個DrinkDetails元件,讓它接收 namedescription,並在元件函式裡宣告:

const [isExpanded, setIsExpanded] = useState(false);

function handleToggle() {
  setIsExpanded(!isExpanded);
}

畫面則根據 isExpanded 決定按鈕文字與介紹是否顯示:

// 示意片段:省略元件外層與樣式。
<button
  type="button"
  aria-expanded={isExpanded}
  onClick={handleToggle}
>
  {isExpanded ? "收合" : "展開"}「{name}」介紹
</button>
{isExpanded ? <p>{description}</p> : null}

useState(false) 讓介紹一開始是收合的。

!isExpanded 把布林值反轉:false 變成 true,再按一次則變回 false。按鈕文字和介紹內容都讀取同一個 state,所以會一起變化。

這裡用三元運算子決定要顯示介紹段落,還是回傳 nullaria-expanded 也會隨著 state 更新,讓輔助工具知道按鈕控制的內容目前是否展開。

之後組合頁面時,會將飲料名稱和介紹內容透過 props 傳入:

<DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
<DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />

兩款飲料的介紹各有自己的 isExpanded。展開紅茶介紹,不會讓綠茶介紹一起展開;收合只會隱藏該款飲料的介紹文字,飲料名稱、杯數和按鈕仍留在畫面上。

外層元件提供名稱和介紹內容,DrinkDetails 自己記住是否展開。DrinkCounter 則管理杯數,兩種操作各自使用需要的 state。

完整組合

現在把剛才的片段組成可以執行的畫面。

首先在專案中新增 app/practice 資料夾,再依照下面的順序新增或替換檔案。

檔案 操作 用途
app/practice/variable-counter.tsx 新增 用一般變數記錄紅茶杯數
app/practice/drink-order.tsx 新增 飲料計數器、介紹與點餐首頁
app/routes/home.tsx 替換 讓首頁顯示 DrinkOrder
app/routes/variable.tsx 新增 顯示一般變數觀察頁
app/routes.ts 替換 設定首頁與 /variable 的路由

1. 新增一般變數觀察元件:app/practice/variable-counter.tsx

// 文章最前面的觀察範例:一般變數可以改值,但不會通知 React 更新畫面。
export function VariableCounter() {
  let quantity = 0;

  function handleAdd() {
    quantity += 1;
    console.log("[一般變數] quantity:", quantity);
  }

  return (
    <section>
      <h2>紅茶杯數(一般變數)</h2>
      <p>已選 {quantity} 杯</p>
      <button type="button" onClick={handleAdd}>
        加一杯
      </button>
    </section>
  );
}

2. 新增飲料點餐元件:app/practice/drink-order.tsx

import { useState } from "react";
import { Link } from "react-router";

type DrinkCounterProps = {
  name: string;
};

function DrinkCounter({ name }: DrinkCounterProps) {
  const [quantity, setQuantity] = useState(0);

  function handleAdd() {
    setQuantity(quantity + 1);
  }

  function handleClear() {
    setQuantity(0);
  }

  return (
    <div>
      <h2>{name}</h2>
      <p>已選 {quantity} 杯</p>
      <div>
        <button type="button" onClick={handleAdd}>
          加一杯
        </button>
        <button type="button" onClick={handleClear}>
          清空杯數
        </button>
      </div>
    </div>
  );
}

type DrinkDetailsProps = {
  name: string;
  description: string;
};

function DrinkDetails({ name, description }: DrinkDetailsProps) {
  const [isExpanded, setIsExpanded] = useState(false);

  function handleToggle() {
    setIsExpanded(!isExpanded);
  }

  return (
    <div>
      <button
        type="button"
        aria-expanded={isExpanded}
        onClick={handleToggle}
      >
        {isExpanded ? "收合" : "展開"}「{name}」介紹
      </button>
      {isExpanded ? <p>{description}</p> : null}
    </div>
  );
}

export function DrinkOrder() {
  return (
    <main>
      <header>
        <p>第二階段 · DAY 9</p>
        <h1>飲料點餐練習</h1>
        <p>先選杯數,再看看飲料介紹。</p>
        <Link to="/variable">比較一般變數</Link>
      </header>
      <div>
        <section>
          <DrinkCounter name="紅茶" />
          <DrinkDetails name="紅茶" description="茶香濃郁,口感醇厚。" />
        </section>
        <section>
          <DrinkCounter name="綠茶" />
          <DrinkDetails name="綠茶" description="清爽茶香,適合喜歡清淡口味的人。" />
        </section>
      </div>
    </main>
  );
}

DrinkCounterDrinkDetails 都定義在 DrinkOrder 外面。
DrinkOrder 把同一款飲料的杯數和介紹放在同一個區塊。
Link 用來切換兩個練習頁面,杯數與介紹的狀態由各自的元件管理。

3. 替換預設首頁:app/routes/home.tsx

import type { Route } from "./+types/home";
import { DrinkOrder } from "../practice/drink-order";

export function meta({}: Route.MetaArgs) {
  return [
    { title: "Day 9|飲料點餐練習" },
    { name: "description", content: "用 state 記住飲料杯數與介紹的展開狀態。" },
  ];
}

export default function Home() {
  return <DrinkOrder />;
}

4. 新增一般變數頁面:app/routes/variable.tsx

import { Link } from "react-router";
import type { Route } from "./+types/variable";
import { VariableCounter } from "../practice/variable-counter";

export function meta({}: Route.MetaArgs) {
  return [{ title: "Day 9|一般變數觀察" }];
}

export default function VariablePage() {
  return (
    <main>
      <header>
        <p>DAY 9 · 一般變數觀察</p>
        <h1>杯數變了,畫面呢?</h1>
        <p>
          開啟瀏覽器 Console,按三次「加一杯」,比較訊息中的數字與畫面上的杯數。
        </p>
      </header>
      <VariableCounter />
      <Link to="/">回到飲料點餐</Link>
    </main>
  );
}

5. 替換路由設定:app/routes.ts

import { type RouteConfig, index, route } from "@react-router/dev/routes";

export default [
  index("routes/home.tsx"), // 首頁訂在 home.tsx
  route("variable", "routes/variable.tsx"), // 新的頁面,會導向 variable.tsx
] satisfies RouteConfig;

這一步完成後,首頁 / 會顯示飲料點餐,/variable 會顯示一般變數觀察頁。

新增路由後,若編輯器暫時找不到 ./+types/variable,可在專案資料夾執行 npm run typecheck 重新產生型別。

全部存檔後,重新載入首頁,應該會看到紅茶和綠茶的點餐區塊。
https://ithelp.ithome.com.tw/upload/images/20260923/20184332ynnUfEjwuz.png

按「比較一般變數」會進入觀察頁。
https://ithelp.ithome.com.tw/upload/images/20260923/20184332VrKWr3H28J.png


今天先這樣吧,其實還要除錯練習,之後回來補。/images/emoticon/emoticon06.gif

參考資料


上一篇
Day 8 : 一觸即發,用事件處理回應操作
系列文
React 入門到實作與除錯|30 天哩ㄟ刻9
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言