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 色碼
本課重點回顧
useState回傳[值, setter]- State 更新是非同步的
- 連續更新要用函式形式
setState(prev => ...) - 不可變性:永遠建立新的物件/陣列,不要直接修改
- 計算成本高的初始值用惰性初始化
useState(() => ...) - 物件用
{...obj}、陣列用[...arr]來建立副本