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;
Related articles
App.js
App.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.js).
Read article →App.test.js
App.test.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/App.test.js).
Read article →index.js
index.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/index.js).
Read article →reportWebVitals.js
reportWebVitals.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/reportWebVitals.js).
Read article →setupTests.js
setupTests.js — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/example/src/setupTests.js).
Read article →Project_HelloReact.jsx
Project_HelloReact.jsx — javascript source code from the React.js learning materials (React.js/lessons/01_introduction/projects/Project_HelloReact.jsx).
Read article →