第 12 章:狀態提升 (Lifting State Up)

狀態提升是 React 中一個核心概念,當多個元件需要共享相同的狀態時,我們需要將這個狀態提升到它們最近的共同父元件中。這樣可以確保資料的一致性,並遵循 React 的單向資料流原則。在這一章中,我們將深入學習何時以及如何進行狀態提升。

12.1 當多個元件需要共享狀態時

12.1.1 識別共享狀態的需求

當我們遇到以下情況時,通常需要考慮狀態提升:

  1. 多個子元件需要存取相同的資料
  2. 一個元件的變更需要影響另一個元件
  3. 元件之間需要同步狀態
  4. 需要在多個元件間傳遞相同的資料

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 狀態提升的步驟

  1. 移除子元件中的 state
  2. 將共享的資料通過 props 傳遞給子元件
  3. 將更新狀態的函式作為 props 傳遞給子元件
  4. 在父元件中統一管理共享的狀態

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)的原則:

  1. 狀態向下流動:父元件通過 props 將資料傳遞給子元件
  2. 事件向上冒泡:子元件通過回調函式將事件傳遞給父元件
  3. 狀態集中管理:共享狀態存放在共同的父元件中
// 資料流示意圖:
// 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 何時進行狀態提升

  1. 兄弟元件需要共享資料時
  2. 多個元件需要反映相同的資料變化時
  3. 需要在多個元件間同步狀態時
  4. 一個元件的操作需要影響另一個元件時

12.4.2 實施原則

  • 最小共同父元件原則:只將狀態提升到必要的層級
  • 避免過度提升:不要將所有狀態都放在最頂層
  • 使用組合:通過組合避免不必要的狀態提升
  • 善用 Custom Hooks:封裝相關的狀態邏輯

function MinimalLiftingExample() { // 這個狀態只被 ComponentA 和 ComponentB 使用 const [sharedState, setSharedState] = useState('');

return (

{/* 不需要 sharedState */}
); }

// 💡 策略 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 開發中的一個重要概念,它幫助我們:

主要優勢:

  1. 資料一致性 - 確保多個元件顯示的資料保持同步
  2. 單一真相來源 - 避免資料重複和不一致的問題
  3. 清晰的資料流 - 遵循單向資料流,讓應用程式更容易理解和除錯
  4. 元件重用性 - 子元件變成受控元件,更容易重用

實施原則:

  1. 識別共享狀態 - 當多個元件需要相同資料時
  2. 找到最近共同父元件 - 將狀態提升到適當的層級
  3. 通過 props 傳遞資料 - 保持單向資料流
  4. 合理的狀態分離 - 避免過度提升,保持效能

進階技巧:

  1. 使用 Custom Hooks - 封裝相關的狀態邏輯
  2. 元件組合 - 通過組合避免不必要的狀態提升
  3. 效能優化 - 使用 React.memo 和 useCallback 避免不必要的重新渲染

掌握狀態提升讓你能夠建立更好的元件架構,創造出更維護友好和高效能的 React 應用程式。在下一章中,我們將學習 Context API,這是處理深層元件樹中狀態共享的另一個重要工具。

results matching ""

    No results matching ""