S SmartDocs
系列: React.js javascript 301 行 · 更新於 2026-04-02

Project_CustomHooksDemo.jsx

React.js/lessons/14_custom_hooks/projects/Project_CustomHooksDemo.jsx

/**
 * Project: 自訂 Hooks 展示 — Custom Hooks 練習
 *
 * 學習重點:
 * - 封裝 useLocalStorage Hook
 * - 封裝 useDebounce Hook
 * - 封裝 useToggle Hook
 * - 封裝 useWindowSize Hook
 * - 在元件中組合使用多個自訂 Hook
 *
 * 使用方式:將此檔案內容複製到你的 App.jsx 中
 */

import { useState, useEffect, useCallback } from 'react';

// ============================================================
// Custom Hooks
// ============================================================

function useLocalStorage(key, initialValue) {
  const [value, setValue] = useState(() => {
    try {
      const saved = localStorage.getItem(key);
      return saved !== null ? JSON.parse(saved) : initialValue;
    } catch {
      return initialValue;
    }
  });

  useEffect(() => {
    localStorage.setItem(key, JSON.stringify(value));
  }, [key, value]);

  return [value, setValue];
}

function useDebounce(value, delay = 300) {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  return debounced;
}

function useToggle(initial = false) {
  const [value, setValue] = useState(initial);
  const toggle = useCallback(() => setValue((v) => !v), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  return { value, toggle, setTrue, setFalse };
}

function useWindowSize() {
  const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight });
  useEffect(() => {
    const handle = () => setSize({ width: window.innerWidth, height: window.innerHeight });
    window.addEventListener('resize', handle);
    return () => window.removeEventListener('resize', handle);
  }, []);
  return size;
}

function useCounter(initial = 0, { min = -Infinity, max = Infinity, step = 1 } = {}) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => Math.min(c + step, max)), [step, max]);
  const decrement = useCallback(() => setCount((c) => Math.max(c - step, min)), [step, min]);
  const reset = useCallback(() => setCount(initial), [initial]);
  const set = useCallback((val) => setCount(Math.min(Math.max(val, min), max)), [min, max]);
  return { count, increment, decrement, reset, set };
}

// ============================================================
// Demo Components
// ============================================================

function DemoCard({ title, hook, children }) {
  return (
    <div style={{
      padding: '24px', borderRadius: '16px', backgroundColor: 'white',
      border: '1px solid #f1f5f9', marginBottom: '16px',
    }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
        <h3 style={{ margin: 0, color: '#0f172a', fontSize: '1.1rem' }}>{title}</h3>
        <code style={{
          padding: '4px 10px', borderRadius: '6px', backgroundColor: '#f1f5f9',
          color: '#4f46e5', fontSize: '0.8rem', fontFamily: 'monospace',
        }}>
          {hook}
        </code>
      </div>
      {children}
    </div>
  );
}

function LocalStorageDemo() {
  const [name, setName] = useLocalStorage('demo_name', '');
  const [color, setColor] = useLocalStorage('demo_color', '#4f46e5');

  return (
    <DemoCard title="持久化儲存" hook="useLocalStorage">
      <p style={{ color: '#64748b', fontSize: '0.85rem', margin: '0 0 12px' }}>
        輸入的資料會自動保存到 localStorage,重新整理頁面後仍然存在。
      </p>
      <div style={{ display: 'flex', gap: '12px', marginBottom: '12px' }}>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="輸入你的名字"
          style={{ flex: 1, padding: '8px 12px', borderRadius: '8px', border: '1px solid #e2e8f0', outline: 'none' }}
        />
        <input
          type="color"
          value={color}
          onChange={(e) => setColor(e.target.value)}
          style={{ width: '44px', height: '38px', borderRadius: '8px', border: '1px solid #e2e8f0', cursor: 'pointer' }}
        />
      </div>
      {name && (
        <p style={{ margin: 0, color, fontWeight: 'bold', fontSize: '1.2rem' }}>
          Hello, {name}!
        </p>
      )}
    </DemoCard>
  );
}

function DebounceDemo() {
  const [input, setInput] = useState('');
  const debounced = useDebounce(input, 500);
  const [searchCount, setSearchCount] = useState(0);

  useEffect(() => {
    if (debounced) {
      setSearchCount((c) => c + 1);
    }
  }, [debounced]);

  return (
    <DemoCard title="防抖搜尋" hook="useDebounce">
      <p style={{ color: '#64748b', fontSize: '0.85rem', margin: '0 0 12px' }}>
        輸入後等 500ms 才觸發搜尋,減少不必要的 API 呼叫。
      </p>
      <input
        value={input}
        onChange={(e) => setInput(e.target.value)}
        placeholder="試著快速輸入..."
        style={{ width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #e2e8f0', outline: 'none', boxSizing: 'border-box', marginBottom: '12px' }}
      />
      <div style={{ display: 'flex', gap: '16px', fontSize: '0.85rem' }}>
        <span style={{ color: '#94a3b8' }}>即時值:<strong style={{ color: '#0f172a' }}>{input || '(空)'}</strong></span>
        <span style={{ color: '#94a3b8' }}>防抖值:<strong style={{ color: '#4f46e5' }}>{debounced || '(空)'}</strong></span>
        <span style={{ color: '#94a3b8' }}>搜尋次數:<strong style={{ color: '#22c55e' }}>{searchCount}</strong></span>
      </div>
    </DemoCard>
  );
}

function ToggleDemo() {
  const modal = useToggle(false);
  const darkMode = useToggle(false);
  const sidebar = useToggle(true);

  return (
    <DemoCard title="開關切換" hook="useToggle">
      <p style={{ color: '#64748b', fontSize: '0.85rem', margin: '0 0 12px' }}>
        管理布林值狀態,提供 toggle / setTrue / setFalse 方法。
      </p>
      <div style={{ display: 'flex', gap: '12px', flexWrap: 'wrap' }}>
        {[
          { label: darkMode.value ? '🌙 深色模式' : '☀️ 淺色模式', state: darkMode },
          { label: sidebar.value ? '📂 側邊欄開啟' : '📁 側邊欄關閉', state: sidebar },
          { label: '📱 開啟 Modal', state: modal },
        ].map((item, i) => (
          <button
            key={i}
            onClick={item.state.toggle}
            style={{
              padding: '8px 16px', borderRadius: '10px', border: 'none',
              backgroundColor: item.state.value ? '#4f46e5' : '#f1f5f9',
              color: item.state.value ? 'white' : '#64748b',
              cursor: 'pointer', fontSize: '0.85rem', transition: 'all 0.2s',
            }}
          >
            {item.label}
          </button>
        ))}
      </div>
      {modal.value && (
        <div style={{
          marginTop: '16px', padding: '16px', borderRadius: '12px',
          backgroundColor: '#eef2ff', border: '1px solid #c7d2fe', textAlign: 'center',
        }}>
          <p style={{ margin: '0 0 8px', color: '#4338ca' }}>Modal 內容</p>
          <button onClick={modal.setFalse} style={{
            padding: '6px 16px', borderRadius: '8px', border: 'none',
            backgroundColor: '#4f46e5', color: 'white', cursor: 'pointer',
          }}>關閉</button>
        </div>
      )}
    </DemoCard>
  );
}

function WindowSizeDemo() {
  const { width, height } = useWindowSize();

  const getDevice = () => {
    if (width < 640) return { label: '📱 手機', color: '#ef4444' };
    if (width < 1024) return { label: '📱 平板', color: '#f59e0b' };
    return { label: '🖥️ 桌面', color: '#22c55e' };
  };

  const device = getDevice();

  return (
    <DemoCard title="視窗尺寸偵測" hook="useWindowSize">
      <p style={{ color: '#64748b', fontSize: '0.85rem', margin: '0 0 12px' }}>
        即時偵測視窗大小,調整瀏覽器視窗大小試試。
      </p>
      <div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
        <div style={{
          padding: '12px 20px', borderRadius: '10px',
          backgroundColor: '#f1f5f9', fontFamily: 'monospace', fontSize: '1.2rem',
          fontWeight: 'bold', color: '#0f172a',
        }}>
          {width} × {height}
        </div>
        <span style={{
          padding: '6px 14px', borderRadius: '8px',
          backgroundColor: `${device.color}15`, color: device.color,
          fontWeight: '600', fontSize: '0.9rem',
        }}>
          {device.label}
        </span>
      </div>
    </DemoCard>
  );
}

function CounterDemo() {
  const counter = useCounter(0, { min: 0, max: 20, step: 1 });

  return (
    <DemoCard title="進階計數器" hook="useCounter">
      <p style={{ color: '#64748b', fontSize: '0.85rem', margin: '0 0 12px' }}>
        支援最小值、最大值、步進值限制。(min: 0, max: 20)
      </p>
      <div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
        <button onClick={counter.decrement} style={{
          width: '40px', height: '40px', borderRadius: '10px', border: '1px solid #e2e8f0',
          backgroundColor: 'white', cursor: 'pointer', fontSize: '1.2rem',
        }}>−</button>
        <span style={{ fontSize: '2rem', fontWeight: 'bold', color: '#0f172a', width: '60px', textAlign: 'center' }}>
          {counter.count}
        </span>
        <button onClick={counter.increment} style={{
          width: '40px', height: '40px', borderRadius: '10px', border: '1px solid #e2e8f0',
          backgroundColor: 'white', cursor: 'pointer', fontSize: '1.2rem',
        }}>+</button>
        <button onClick={counter.reset} style={{
          padding: '8px 16px', borderRadius: '8px', border: '1px solid #e2e8f0',
          backgroundColor: 'white', cursor: 'pointer', fontSize: '0.85rem', color: '#64748b',
        }}>重置</button>
      </div>
      {/* 進度條 */}
      <div style={{ marginTop: '12px', height: '6px', backgroundColor: '#f1f5f9', borderRadius: '3px', overflow: 'hidden' }}>
        <div style={{
          height: '100%', width: `${(counter.count / 20) * 100}%`,
          backgroundColor: '#4f46e5', borderRadius: '3px', transition: 'width 0.2s',
        }} />
      </div>
    </DemoCard>
  );
}

// ============================================================
// App
// ============================================================
function App() {
  return (
    <div style={{
      maxWidth: '700px', margin: '0 auto', padding: '32px 16px',
      fontFamily: "'Segoe UI', 'Noto Sans TC', sans-serif",
    }}>
      <h1 style={{ color: '#0f172a', marginBottom: '4px' }}>Custom Hooks 展示</h1>
      <p style={{ color: '#94a3b8', marginTop: 0, marginBottom: '32px' }}>
        5 個實用的自訂 Hook 與互動式範例
      </p>

      <LocalStorageDemo />
      <DebounceDemo />
      <ToggleDemo />
      <WindowSizeDemo />
      <CounterDemo />
    </div>
  );
}

export default App;

相關文章