第 12 章:狀態提升 (Lifting State Up)
狀態提升是 React 中一個核心概念,當多個元件需要共享相同的狀態時,我們需要將這個狀態提升到它們最近的共同父元件中。這樣可以確保資料的一致性,並遵循 React 的單向資料流原則。在這一章中,我們將深入學習何時以及如何進行狀態提升。
12.1 當多個元件需要共享狀態時
12.1.1 識別共享狀態的需求
當我們遇到以下情況時,通常需要考慮狀態提升:
- 多個子元件需要存取相同的資料
- 一個元件的變更需要影響另一個元件
- 元件之間需要同步狀態
- 需要在多個元件間傳遞相同的資料
12.1.2 問題示例:分離的溫度輸入
讓我們看一個典型的例子,兩個溫度輸入元件需要保持同步:
// ❌ 問題版本:各自維護狀態,無法同步
import React, { useState } from 'react';
// 攝氏溫度輸入元件
function CelsiusInput() {
const [temperature, setTemperature] = useState('');
const handleChange = (e) => {
setTemperature(e.target.value);
};
return (
<fieldset>
<legend>請輸入攝氏溫度:</legend>
<input
value={temperature}
onChange={handleChange}
placeholder="攝氏溫度"
/>
</fieldset>
);
}
// 華氏溫度輸入元件
function FahrenheitInput() {
const [temperature, setTemperature] = useState('');
const handleChange = (e) => {
setTemperature(e.target.value);
};
return (
<fieldset>
<legend>請輸入華氏溫度:</legend>
<input
value={temperature}
onChange={handleChange}
placeholder="華氏溫度"
/>
</fieldset>
);
}
// 主要元件
function TemperatureApp() {
return (
<div>
<h2>溫度轉換器</h2>
<CelsiusInput />
<FahrenheitInput />
</div>
);
}
在上面的例子中,兩個溫度輸入元件無法相互同步,這不符合我們的需求。
12.2 將狀態提升到最近的共同父元件
12.2.1 狀態提升的步驟
- 移除子元件中的 state
- 將共享的資料通過 props 傳遞給子元件
- 將更新狀態的函式作為 props 傳遞給子元件
- 在父元件中統一管理共享的狀態
12.2.2 改進的溫度轉換器
import React, { useState } from 'react';
// 溫度轉換函式
function toCelsius(fahrenheit) {
return ((fahrenheit - 32) * 5) / 9;
}
function toFahrenheit(celsius) {
return (celsius * 9) / 5 + 32;
}
function tryConvert(temperature, convert) {
const input = parseFloat(temperature);
if (Number.isNaN(input)) {
return '';
}
const output = convert(input);
const rounded = Math.round(output * 1000) / 1000;
return rounded.toString();
}
// ✅ 改進版本:受控元件,通過 props 接收狀態和更新函式
function TemperatureInput({ scale, temperature, onTemperatureChange }) {
const handleChange = (e) => {
onTemperatureChange(e.target.value);
};
const scaleNames = {
c: '攝氏',
f: '華氏'
};
return (
<fieldset>
<legend>請輸入{scaleNames[scale]}溫度:</legend>
<input
value={temperature}
onChange={handleChange}
placeholder={`${scaleNames[scale]}溫度`}
/>
</fieldset>
);
}
// 水沸騰判斷元件
function BoilingVerdict({ celsius }) {
if (celsius >= 100) {
return <p style={{color: 'red', fontWeight: 'bold'}}>水會沸騰!🔥</p>;
}
return <p style={{color: 'blue'}}>水不會沸騰。❄️</p>;
}
// ✅ 父元件統一管理狀態
function TemperatureCalculator() {
const [temperature, setTemperature] = useState('');
const [scale, setScale] = useState('c');
const handleCelsiusChange = (temperature) => {
setScale('c');
setTemperature(temperature);
};
const handleFahrenheitChange = (temperature) => {
setScale('f');
setTemperature(temperature);
};
const celsius = scale === 'f'
? tryConvert(temperature, toCelsius)
: temperature;
const fahrenheit = scale === 'c'
? tryConvert(temperature, toFahrenheit)
: temperature;
return (
<div>
<h2>溫度轉換器</h2>
<TemperatureInput
scale="c"
temperature={celsius}
onTemperatureChange={handleCelsiusChange}
/>
<TemperatureInput
scale="f"
temperature={fahrenheit}
onTemperatureChange={handleFahrenheitChange}
/>
<BoilingVerdict celsius={parseFloat(celsius)} />
</div>
);
}
12.2.3 購物車範例
讓我們看另一個實際的例子:購物車系統
import React, { useState } from 'react';
// 商品卡片元件
function ProductCard({ product, onAddToCart }) {
return (
<div style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '16px',
margin: '8px',
maxWidth: '200px'
}}>
<h3>{product.name}</h3>
<p>價格: ${product.price}</p>
<p>庫存: {product.stock}</p>
<button
onClick={() => onAddToCart(product)}
disabled={product.stock === 0}
style={{
backgroundColor: product.stock > 0 ? '#007bff' : '#6c757d',
color: 'white',
border: 'none',
padding: '8px 16px',
borderRadius: '4px',
cursor: product.stock > 0 ? 'pointer' : 'not-allowed'
}}
>
{product.stock > 0 ? '加入購物車' : '缺貨'}
</button>
</div>
);
}
// 購物車元件
function ShoppingCart({ items, onUpdateQuantity, onRemoveItem }) {
const total = items.reduce((sum, item) => sum + (item.price * item.quantity), 0);
if (items.length === 0) {
return (
<div style={{ padding: '16px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>購物車</h3>
<p>購物車是空的</p>
</div>
);
}
return (
<div style={{ padding: '16px', border: '1px solid #ddd', borderRadius: '8px' }}>
<h3>購物車 ({items.length} 項商品)</h3>
{items.map(item => (
<div key={item.id} style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid #eee'
}}>
<span>{item.name}</span>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<button
onClick={() => onUpdateQuantity(item.id, item.quantity - 1)}
disabled={item.quantity <= 1}
>
-
</button>
<span>{item.quantity}</span>
<button
onClick={() => onUpdateQuantity(item.id, item.quantity + 1)}
>
+
</button>
<span>${(item.price * item.quantity).toFixed(2)}</span>
<button
onClick={() => onRemoveItem(item.id)}
style={{ color: 'red', marginLeft: '8px' }}
>
移除
</button>
</div>
</div>
))}
<div style={{ marginTop: '16px', fontWeight: 'bold' }}>
總計: ${total.toFixed(2)}
</div>
</div>
);
}
// 主要的購物應用程式
function ShoppingApp() {
const [products] = useState([
{ id: 1, name: 'iPhone 15', price: 999, stock: 5 },
{ id: 2, name: 'MacBook Pro', price: 1999, stock: 3 },
{ id: 3, name: 'AirPods', price: 179, stock: 0 },
{ id: 4, name: 'iPad', price: 599, stock: 8 }
]);
const [cartItems, setCartItems] = useState([]);
// 添加商品到購物車
const handleAddToCart = (product) => {
setCartItems(prevItems => {
const existingItem = prevItems.find(item => item.id === product.id);
if (existingItem) {
// 如果商品已存在,增加數量
return prevItems.map(item =>
item.id === product.id
? { ...item, quantity: item.quantity + 1 }
: item
);
} else {
// 如果是新商品,添加到購物車
return [...prevItems, { ...product, quantity: 1 }];
}
});
};
// 更新商品數量
const handleUpdateQuantity = (productId, newQuantity) => {
if (newQuantity <= 0) {
handleRemoveItem(productId);
return;
}
setCartItems(prevItems =>
prevItems.map(item =>
item.id === productId
? { ...item, quantity: newQuantity }
: item
)
);
};
// 移除商品
const handleRemoveItem = (productId) => {
setCartItems(prevItems =>
prevItems.filter(item => item.id !== productId)
);
};
return (
<div style={{ padding: '20px' }}>
<h1>線上商店</h1>
<div style={{ display: 'flex', gap: '20px' }}>
<div>
<h2>商品列表</h2>
<div style={{ display: 'flex', flexWrap: 'wrap' }}>
{products.map(product => (
<ProductCard
key={product.id}
product={product}
onAddToCart={handleAddToCart}
/>
))}
</div>
</div>
<div style={{ minWidth: '300px' }}>
<ShoppingCart
items={cartItems}
onUpdateQuantity={handleUpdateQuantity}
onRemoveItem={handleRemoveItem}
/>
</div>
</div>
</div>
);
}
12.3 單向資料流的回顧
12.3.1 React 的資料流原則
React 遵循單向資料流(One-Way Data Flow)的原則:
- 狀態向下流動:父元件通過 props 將資料傳遞給子元件
- 事件向上冒泡:子元件通過回調函式將事件傳遞給父元件
- 狀態集中管理:共享狀態存放在共同的父元件中
// 資料流示意圖:
// Parent Component (狀態存放處)
// ↓ props (資料向下流動)
// Child Component A Child Component B
// ↑ callback ↑ callback
// (事件向上冒泡) (事件向上冒泡)
12.3.2 單向資料流的優勢
import React, { useState } from 'react';
// 展示單向資料流的完整範例
function DataFlowExample() {
// 1. 狀態集中在父元件
const [users, setUsers] = useState([
{ id: 1, name: '張三', active: true },
{ id: 2, name: '李四', active: false },
{ id: 3, name: '王五', active: true }
]);
const [selectedUserId, setSelectedUserId] = useState(null);
// 2. 事件處理函式在父元件定義
const handleUserToggle = (userId) => {
setUsers(prevUsers =>
prevUsers.map(user =>
user.id === userId
? { ...user, active: !user.active }
: user
)
);
};
const handleUserSelect = (userId) => {
setSelectedUserId(userId);
};
const selectedUser = users.find(user => user.id === selectedUserId);
const activeUsers = users.filter(user => user.active);
return (
<div style={{ padding: '20px' }}>
<h2>單向資料流示例</h2>
{/* 3. 將資料和函式通過 props 傳遞給子元件 */}
<UserStats users={users} activeCount={activeUsers.length} />
<UserList
users={users}
selectedUserId={selectedUserId}
onUserToggle={handleUserToggle}
onUserSelect={handleUserSelect}
/>
<UserDetail user={selectedUser} />
</div>
);
}
// 使用者統計元件(純顯示,接收 props)
function UserStats({ users, activeCount }) {
return (
<div style={{
backgroundColor: '#f8f9fa',
padding: '16px',
borderRadius: '8px',
marginBottom: '16px'
}}>
<h3>使用者統計</h3>
<p>總使用者數: {users.length}</p>
<p>活躍使用者數: {activeCount}</p>
<p>非活躍使用者數: {users.length - activeCount}</p>
</div>
);
}
// 使用者列表元件
function UserList({ users, selectedUserId, onUserToggle, onUserSelect }) {
return (
<div style={{ marginBottom: '16px' }}>
<h3>使用者列表</h3>
{users.map(user => (
<UserItem
key={user.id}
user={user}
isSelected={user.id === selectedUserId}
onToggle={() => onUserToggle(user.id)}
onSelect={() => onUserSelect(user.id)}
/>
))}
</div>
);
}
// 使用者項目元件
function UserItem({ user, isSelected, onToggle, onSelect }) {
return (
<div style={{
display: 'flex',
alignItems: 'center',
padding: '8px',
backgroundColor: isSelected ? '#e3f2fd' : 'transparent',
border: '1px solid #ddd',
borderRadius: '4px',
marginBottom: '4px'
}}>
<button
onClick={onSelect}
style={{
marginRight: '12px',
padding: '4px 8px',
backgroundColor: isSelected ? '#2196f3' : '#f5f5f5'
}}
>
{isSelected ? '已選中' : '選擇'}
</button>
<span style={{
flex: 1,
color: user.active ? '#000' : '#999',
textDecoration: user.active ? 'none' : 'line-through'
}}>
{user.name}
</span>
<button
onClick={onToggle}
style={{
padding: '4px 8px',
backgroundColor: user.active ? '#4caf50' : '#f44336',
color: 'white',
border: 'none',
borderRadius: '4px'
}}
>
{user.active ? '停用' : '啟用'}
</button>
</div>
);
}
// 使用者詳情元件
function UserDetail({ user }) {
if (!user) {
return (
<div style={{
padding: '16px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: '#f9f9f9'
}}>
<p>請選擇一個使用者以查看詳情</p>
</div>
);
}
return (
<div style={{
padding: '16px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: '#fff'
}}>
<h3>使用者詳情</h3>
<p><strong>ID:</strong> {user.id}</p>
<p><strong>姓名:</strong> {user.name}</p>
<p><strong>狀態:</strong>
<span style={{
color: user.active ? '#4caf50' : '#f44336',
fontWeight: 'bold'
}}>
{user.active ? ' 活躍' : ' 非活躍'}
</span>
</p>
</div>
);
}
12.4 狀態提升的最佳實踐
12.4.1 何時進行狀態提升
- 兄弟元件需要共享資料時
- 多個元件需要反映相同的資料變化時
- 需要在多個元件間同步狀態時
- 一個元件的操作需要影響另一個元件時
12.4.2 實施原則
- 最小共同父元件原則:只將狀態提升到必要的層級
- 避免過度提升:不要將所有狀態都放在最頂層
- 使用組合:通過組合避免不必要的狀態提升
- 善用 Custom Hooks:封裝相關的狀態邏輯
function MinimalLiftingExample() { // 這個狀態只被 ComponentA 和 ComponentB 使用 const [sharedState, setSharedState] = useState('');
return (
// 💡 策略 2: 使用組合來避免過度提升 function CompositionExample() { return (
function UserSection() { const [userState, setUserState] = useState({});
return (
// 💡 策略 3: 使用 Custom Hook 封裝相關邏輯 function useSharedCounter(initialValue = 0) { const [count, setCount] = useState(initialValue);
const increment = () => setCount(prev => prev + 1); const decrement = () => setCount(prev => prev - 1); const reset = () => setCount(initialValue);
return { count, increment, decrement, reset }; }
function SharedCounterExample() { const { count, increment, decrement, reset } = useSharedCounter(0);
return (
#### 12.4.3 避免過度提升
```javascript
// ❌ 過度提升:將所有狀態都放在最頂層
function OverLiftedExample() {
const [userProfile, setUserProfile] = useState({});
const [shoppingCart, setShoppingCart] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [modalOpen, setModalOpen] = useState(false);
const [notifications, setNotifications] = useState([]);
// 太多不相關的狀態...
return (
<div>
<Header
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
notifications={notifications}
/>
<UserSection
profile={userProfile}
onProfileUpdate={setUserProfile}
/>
<ProductSection
cart={shoppingCart}
onCartUpdate={setShoppingCart}
/>
<Modal
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
/>
</div>
);
}
// ✅ 適當的狀態分離
function WellStructuredExample() {
return (
<div>
<HeaderSection />
<UserSection />
<ShoppingSection />
<ModalProvider>
<App />
</ModalProvider>
</div>
);
}
function HeaderSection() {
const [searchQuery, setSearchQuery] = useState('');
const [notifications, setNotifications] = useState([]);
return (
<Header
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
notifications={notifications}
/>
);
}
12.5 狀態提升與效能考慮
12.5.1 使用 React.memo 避免不必要的重新渲染
import React, { useState, memo, useCallback } from 'react';
// 使用 memo 包裝元件,避免不必要的重新渲染
const ExpensiveComponent = memo(function ExpensiveComponent({
data,
onUpdate
}) {
console.log('ExpensiveComponent 重新渲染');
return (
<div>
<h3>複雜元件</h3>
<p>資料: {JSON.stringify(data)}</p>
<button onClick={() => onUpdate('new value')}>
更新資料
</button>
</div>
);
});
const SimpleComponent = memo(function SimpleComponent({ count }) {
console.log('SimpleComponent 重新渲染');
return (
<div>
<h3>簡單元件</h3>
<p>計數: {count}</p>
</div>
);
});
function OptimizedParent() {
const [count, setCount] = useState(0);
const [expensiveData, setExpensiveData] = useState({ value: 'initial' });
// 使用 useCallback 確保函式引用穩定
const handleExpensiveUpdate = useCallback((newValue) => {
setExpensiveData({ value: newValue });
}, []);
return (
<div>
<h2>效能優化範例</h2>
<button onClick={() => setCount(count + 1)}>
增加計數: {count}
</button>
{/* SimpleComponent 只會在 count 改變時重新渲染 */}
<SimpleComponent count={count} />
{/* ExpensiveComponent 只會在 expensiveData 或 handleExpensiveUpdate 改變時重新渲染 */}
<ExpensiveComponent
data={expensiveData}
onUpdate={handleExpensiveUpdate}
/>
</div>
);
}
12.6 總結
狀態提升是 React 開發中的一個重要概念,它幫助我們:
主要優勢:
- 資料一致性 - 確保多個元件顯示的資料保持同步
- 單一真相來源 - 避免資料重複和不一致的問題
- 清晰的資料流 - 遵循單向資料流,讓應用程式更容易理解和除錯
- 元件重用性 - 子元件變成受控元件,更容易重用
實施原則:
- 識別共享狀態 - 當多個元件需要相同資料時
- 找到最近共同父元件 - 將狀態提升到適當的層級
- 通過 props 傳遞資料 - 保持單向資料流
- 合理的狀態分離 - 避免過度提升,保持效能
進階技巧:
- 使用 Custom Hooks - 封裝相關的狀態邏輯
- 元件組合 - 通過組合避免不必要的狀態提升
- 效能優化 - 使用 React.memo 和 useCallback 避免不必要的重新渲染
掌握狀態提升讓你能夠建立更好的元件架構,創造出更維護友好和高效能的 React 應用程式。在下一章中,我們將學習 Context API,這是處理深層元件樹中狀態共享的另一個重要工具。