5.1 什麼是 State?

State 是元件內部的「記憶」,用來儲存會隨時間改變的資料。當 state 改變時,React 會自動重新渲染元件。

Props vs State

比較 Props State
來源 父元件傳入 元件自己管理
可否修改 唯讀 可透過 setter 修改
用途 配置元件 追蹤互動/變化的資料
觸發渲染 父元件重渲染時 呼叫 setter 時

5.2 useState 基本用法

import { useState } from 'react';

function Counter() {
  // useState 回傳一個陣列:[目前的值, 更新函式]
  const [count, setCount] = useState(0);
  //     ↑ 狀態值   ↑ setter     ↑ 初始值

  return (
    <div>
      <h1>計數器:{count}</h1>
      <button onClick={() => setCount(count + 1)}>+1</button>
      <button onClick={() => setCount(count - 1)}>-1</button>
      <button onClick={() => setCount(0)}>重置</button>
    </div>
  );
}

多個 state

import { useState } from 'react';

function UserForm() {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');
  const [age, setAge] = useState(0);
  const [isSubscribed, setIsSubscribed] = useState(false);

  return (
    <div style={{ maxWidth: '400px', margin: '20px auto' }}>
      <div style={{ marginBottom: '12px' }}>
        <label>姓名:</label>
        <input value={name} onChange={(e) => setName(e.target.value)} />
      </div>
      <div style={{ marginBottom: '12px' }}>
        <label>Email:</label>
        <input value={email} onChange={(e) => setEmail(e.target.value)} />
      </div>
      <div style={{ marginBottom: '12px' }}>
        <label>年齡:</label>
        <input
          type="number"
          value={age}
          onChange={(e) => setAge(Number(e.target.value))}
        />
      </div>
      <div style={{ marginBottom: '12px' }}>
        <label>
          <input
            type="checkbox"
            checked={isSubscribed}
            onChange={(e) => setIsSubscribed(e.target.checked)}
          />
          訂閱電子報
        </label>
      </div>

      <div style={{ marginTop: '20px', padding: '16px', backgroundColor: '#f7fafc', borderRadius: '8px' }}>
        <h3>預覽:</h3>
        <p>姓名:{name || '(未填寫)'}</p>
        <p>Email:{email || '(未填寫)'}</p>
        <p>年齡:{age}</p>
        <p>訂閱:{isSubscribed ? '是' : '否'}</p>
      </div>
    </div>
  );
}

5.3 State 更新的重要觀念

觀念 1:State 更新是非同步的

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

  const handleClick = () => {
    setCount(count + 1);
    console.log(count); // ⚠️ 仍然是舊的值!不是 +1 後的值
    // 因為 setCount 是非同步的,state 在下次渲染才會更新
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

觀念 2:批次更新 (Batching)

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

  const handleClick = () => {
    // ❌ 這不會加 3!三次 setCount 都是基於同一個 count 值
    setCount(count + 1); // count = 0, 所以 0 + 1 = 1
    setCount(count + 1); // count 仍然是 0, 所以 0 + 1 = 1
    setCount(count + 1); // count 仍然是 0, 所以 0 + 1 = 1
    // 最終結果:count = 1(不是 3)
  };

  const handleClickCorrect = () => {
    // ✅ 使用函式形式的更新(functional update)
    setCount((prev) => prev + 1); // 0 + 1 = 1
    setCount((prev) => prev + 1); // 1 + 1 = 2
    setCount((prev) => prev + 1); // 2 + 1 = 3
    // 最終結果:count = 3
  };

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={handleClick}>錯誤的 +3</button>
      <button onClick={handleClickCorrect}>正確的 +3</button>
    </div>
  );
}

觀念 3:不可變性 (Immutability)

絕對不要直接修改 state! 必須建立新的物件或陣列。

function ImmutabilityDemo() {
  const [user, setUser] = useState({
    name: 'Alice',
    age: 25,
    address: {
      city: 'Taipei',
      country: 'Taiwan',
    },
  });

  const [items, setItems] = useState(['蘋果', '香蕉']);

  // ============================================================
  // 更新物件
  // ============================================================

  // ❌ 錯誤:直接修改物件
  const wrongUpdate = () => {
    user.name = 'Bob'; // 這不會觸發重新渲染!
    setUser(user);     // React 認為是同一個參考,不會更新
  };

  // ✅ 正確:建立新物件
  const correctUpdate = () => {
    setUser({ ...user, name: 'Bob' });
  };

  // ✅ 更新巢狀物件
  const updateNestedObject = () => {
    setUser({
      ...user,
      address: {
        ...user.address,
        city: 'Kaohsiung',
      },
    });
  };

  // ============================================================
  // 更新陣列
  // ============================================================

  // ❌ 錯誤:使用 push(直接修改原陣列)
  const wrongAdd = () => {
    items.push('橘子'); // 不會觸發渲染!
    setItems(items);
  };

  // ✅ 正確:新增元素
  const addItem = () => {
    setItems([...items, '橘子']);
  };

  // ✅ 正確:移除元素
  const removeItem = (index) => {
    setItems(items.filter((_, i) => i !== index));
  };

  // ✅ 正確:更新特定元素
  const updateItem = (index, newValue) => {
    setItems(items.map((item, i) => (i === index ? newValue : item)));
  };

  return (
    <div>
      <h2>{user.name}, {user.age}</h2>
      <p>{user.address.city}, {user.address.country}</p>
      <button onClick={correctUpdate}>改名為 Bob</button>
      <button onClick={updateNestedObject}>搬到高雄</button>

      <h3>水果列表</h3>
      <ul>
        {items.map((item, i) => (
          <li key={i}>
            {item}
            <button onClick={() => removeItem(i)}>刪除</button>
          </li>
        ))}
      </ul>
      <button onClick={addItem}>新增橘子</button>
    </div>
  );
}

5.4 物件與陣列 State 的常見操作

物件操作速查表

const [user, setUser] = useState({ name: 'Alice', age: 25 });

// 更新單一屬性
setUser({ ...user, name: 'Bob' });

// 更新多個屬性
setUser({ ...user, name: 'Bob', age: 30 });

// 刪除屬性
const { age, ...rest } = user;
setUser(rest);

// 動態屬性名
const field = 'name';
setUser({ ...user, [field]: 'Bob' });

陣列操作速查表

const [items, setItems] = useState([1, 2, 3, 4, 5]);

// 新增到末尾
setItems([...items, 6]);

// 新增到開頭
setItems([0, ...items]);

// 在特定位置插入
const insertAt = 2;
setItems([...items.slice(0, insertAt), 99, ...items.slice(insertAt)]);

// 移除特定索引
setItems(items.filter((_, index) => index !== 2));

// 移除特定值
setItems(items.filter((item) => item !== 3));

// 更新特定元素
setItems(items.map((item, i) => (i === 2 ? 99 : item)));

// 排序(記得建立新陣列)
setItems([...items].sort((a, b) => a - b));

// 反轉
setItems([...items].reverse());

物件陣列操作

const [todos, setTodos] = useState([
  { id: 1, text: '學 React', done: false },
  { id: 2, text: '寫專案', done: false },
]);

// 新增
setTodos([...todos, { id: 3, text: '部署', done: false }]);

// 切換某筆的 done
setTodos(todos.map((todo) =>
  todo.id === 1 ? { ...todo, done: !todo.done } : todo
));

// 刪除某筆
setTodos(todos.filter((todo) => todo.id !== 1));

// 更新某筆的 text
setTodos(todos.map((todo) =>
  todo.id === 1 ? { ...todo, text: '學 React Hooks' } : todo
));

5.5 useState 的初始化技巧

惰性初始化 (Lazy Initialization)

如果初始值的計算成本很高,可以傳入函式:

// ❌ 每次渲染都會執行 expensiveComputation()
const [data, setData] = useState(expensiveComputation());

// ✅ 只有在第一次渲染時執行
const [data, setData] = useState(() => expensiveComputation());

// 實際案例:從 localStorage 讀取
const [theme, setTheme] = useState(() => {
  const saved = localStorage.getItem('theme');
  return saved || 'light';
});

// 實際案例:初始化大量資料
const [matrix, setMatrix] = useState(() => {
  return Array.from({ length: 100 }, (_, row) =>
    Array.from({ length: 100 }, (_, col) => row * 100 + col)
  );
});

5.6 完整範例:待辦事項應用

import { useState } from 'react';

function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: '學習 React 基礎', done: true },
    { id: 2, text: '練習 useState', done: false },
    { id: 3, text: '建立待辦事項 App', done: false },
  ]);
  const [inputValue, setInputValue] = useState('');
  const [filter, setFilter] = useState('all'); // 'all' | 'active' | 'completed'

  // 新增待辦
  const addTodo = () => {
    if (inputValue.trim() === '') return;
    const newTodo = {
      id: Date.now(),
      text: inputValue.trim(),
      done: false,
    };
    setTodos([...todos, newTodo]);
    setInputValue('');
  };

  // 切換完成狀態
  const toggleTodo = (id) => {
    setTodos(todos.map((todo) =>
      todo.id === id ? { ...todo, done: !todo.done } : todo
    ));
  };

  // 刪除待辦
  const deleteTodo = (id) => {
    setTodos(todos.filter((todo) => todo.id !== id));
  };

  // 清除已完成
  const clearCompleted = () => {
    setTodos(todos.filter((todo) => !todo.done));
  };

  // 過濾待辦
  const filteredTodos = todos.filter((todo) => {
    if (filter === 'active') return !todo.done;
    if (filter === 'completed') return todo.done;
    return true;
  });

  // 統計
  const activeCount = todos.filter((t) => !t.done).length;
  const completedCount = todos.filter((t) => t.done).length;

  return (
    <div style={{
      maxWidth: '500px',
      margin: '40px auto',
      fontFamily: "'Segoe UI', sans-serif",
    }}>
      <h1 style={{ textAlign: 'center', color: '#2d3748' }}>
        待辦事項
      </h1>

      {/* 輸入區 */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '20px' }}>
        <input
          value={inputValue}
          onChange={(e) => setInputValue(e.target.value)}
          onKeyDown={(e) => e.key === 'Enter' && addTodo()}
          placeholder="輸入新的待辦事項..."
          style={{
            flex: 1,
            padding: '10px 14px',
            border: '2px solid #e2e8f0',
            borderRadius: '8px',
            fontSize: '1rem',
            outline: 'none',
          }}
        />
        <button
          onClick={addTodo}
          style={{
            padding: '10px 20px',
            backgroundColor: '#4299e1',
            color: 'white',
            border: 'none',
            borderRadius: '8px',
            cursor: 'pointer',
            fontWeight: 'bold',
          }}
        >
          新增
        </button>
      </div>

      {/* 過濾按鈕 */}
      <div style={{ display: 'flex', gap: '8px', marginBottom: '16px' }}>
        {['all', 'active', 'completed'].map((f) => (
          <button
            key={f}
            onClick={() => setFilter(f)}
            style={{
              padding: '6px 14px',
              borderRadius: '20px',
              border: 'none',
              backgroundColor: filter === f ? '#4299e1' : '#edf2f7',
              color: filter === f ? 'white' : '#4a5568',
              cursor: 'pointer',
              fontSize: '0.9rem',
            }}
          >
            {f === 'all' ? '全部' : f === 'active' ? '未完成' : '已完成'}
          </button>
        ))}
      </div>

      {/* 待辦列表 */}
      <div>
        {filteredTodos.length === 0 ? (
          <p style={{ textAlign: 'center', color: '#a0aec0', padding: '20px' }}>
            沒有待辦事項
          </p>
        ) : (
          filteredTodos.map((todo) => (
            <div
              key={todo.id}
              style={{
                display: 'flex',
                alignItems: 'center',
                gap: '12px',
                padding: '12px',
                borderBottom: '1px solid #edf2f7',
              }}
            >
              <input
                type="checkbox"
                checked={todo.done}
                onChange={() => toggleTodo(todo.id)}
                style={{ width: '18px', height: '18px', cursor: 'pointer' }}
              />
              <span style={{
                flex: 1,
                textDecoration: todo.done ? 'line-through' : 'none',
                color: todo.done ? '#a0aec0' : '#2d3748',
              }}>
                {todo.text}
              </span>
              <button
                onClick={() => deleteTodo(todo.id)}
                style={{
                  background: 'none',
                  border: 'none',
                  color: '#e53e3e',
                  cursor: 'pointer',
                  fontSize: '1.2rem',
                }}
              >
                ×
              </button>
            </div>
          ))
        )}
      </div>

      {/* 統計欄 */}
      <div style={{
        display: 'flex',
        justifyContent: 'space-between',
        alignItems: 'center',
        marginTop: '16px',
        padding: '12px',
        backgroundColor: '#f7fafc',
        borderRadius: '8px',
        fontSize: '0.9rem',
        color: '#718096',
      }}>
        <span>{activeCount} 項未完成</span>
        {completedCount > 0 && (
          <button
            onClick={clearCompleted}
            style={{
              background: 'none',
              border: 'none',
              color: '#e53e3e',
              cursor: 'pointer',
              fontSize: '0.85rem',
            }}
          >
            清除已完成 ({completedCount})
          </button>
        )}
      </div>
    </div>
  );
}

export default TodoApp;

5.7 練習題

練習 1:建立一個購物車計數器

建立一個商品卡片,包含 +/- 按鈕來控制數量,並即時顯示小計金額。

練習 2:建立一個簡單的記事本

  • 可新增、刪除、編輯筆記
  • 每則筆記有標題和內容
  • 使用物件陣列來管理 state

練習 3:建立一個顏色選擇器

  • 用三個 range input 控制 R、G、B 值
  • 即時預覽混合後的顏色
  • 顯示對應的 HEX 和 RGB 色碼

本課重點回顧

  1. useState 回傳 [值, setter]
  2. State 更新是非同步
  3. 連續更新要用函式形式 setState(prev => ...)
  4. 不可變性:永遠建立新的物件/陣列,不要直接修改
  5. 計算成本高的初始值用惰性初始化 useState(() => ...)
  6. 物件用 {...obj}、陣列用 [...arr] 來建立副本