18.1 使用 fetch 呼叫 API
import { useState, useEffect } from 'react';
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
async function fetchUsers() {
try {
setLoading(true);
const response = await fetch('https://jsonplaceholder.typicode.com/users', {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
setUsers(data);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
}
fetchUsers();
return () => controller.abort();
}, []);
if (loading) return <p>載入中...</p>;
if (error) return <p style={{ color: 'red' }}>錯誤:{error}</p>;
return (
<ul>
{users.map((user) => (
<li key={user.id}>{user.name} — {user.email}</li>
))}
</ul>
);
}
18.2 使用 axios
npm install axios
import { useState, useEffect } from 'react';
import axios from 'axios';
// 建立 axios 實例
const api = axios.create({
baseURL: 'https://jsonplaceholder.typicode.com',
timeout: 10000,
headers: { 'Content-Type': 'application/json' },
});
// 請求攔截器
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// 回應攔截器
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
console.log('未授權,請重新登入');
}
return Promise.reject(error);
}
);
function PostManager() {
const [posts, setPosts] = useState([]);
const [loading, setLoading] = useState(false);
const [newPost, setNewPost] = useState({ title: '', body: '' });
// GET - 取得所有文章
const fetchPosts = async () => {
setLoading(true);
try {
const { data } = await api.get('/posts', { params: { _limit: 10 } });
setPosts(data);
} catch (error) {
alert('取得文章失敗:' + error.message);
} finally {
setLoading(false);
}
};
// POST - 新增文章
const createPost = async () => {
try {
const { data } = await api.post('/posts', {
...newPost,
userId: 1,
});
setPosts([data, ...posts]);
setNewPost({ title: '', body: '' });
} catch (error) {
alert('新增失敗:' + error.message);
}
};
// PUT - 更新文章
const updatePost = async (id, updatedData) => {
try {
const { data } = await api.put(`/posts/${id}`, updatedData);
setPosts(posts.map((p) => (p.id === id ? data : p)));
} catch (error) {
alert('更新失敗:' + error.message);
}
};
// DELETE - 刪除文章
const deletePost = async (id) => {
try {
await api.delete(`/posts/${id}`);
setPosts(posts.filter((p) => p.id !== id));
} catch (error) {
alert('刪除失敗:' + error.message);
}
};
useEffect(() => {
fetchPosts();
}, []);
return (
<div style={{ maxWidth: '600px', margin: '20px auto', fontFamily: 'sans-serif' }}>
<h1>文章管理</h1>
{/* 新增表單 */}
<div style={{
padding: '16px',
backgroundColor: '#f7fafc',
borderRadius: '8px',
marginBottom: '20px',
}}>
<input
value={newPost.title}
onChange={(e) => setNewPost({ ...newPost, title: e.target.value })}
placeholder="標題"
style={{ width: '100%', padding: '8px', marginBottom: '8px', boxSizing: 'border-box' }}
/>
<textarea
value={newPost.body}
onChange={(e) => setNewPost({ ...newPost, body: e.target.value })}
placeholder="內容"
rows={3}
style={{ width: '100%', padding: '8px', marginBottom: '8px', boxSizing: 'border-box' }}
/>
<button onClick={createPost} style={{
padding: '8px 20px',
backgroundColor: '#48bb78',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
}}>
新增文章
</button>
</div>
{/* 文章列表 */}
{loading ? (
<p>載入中...</p>
) : (
posts.map((post) => (
<div key={post.id} style={{
padding: '16px',
borderBottom: '1px solid #e2e8f0',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
}}>
<div>
<h3 style={{ margin: '0 0 4px' }}>{post.title}</h3>
<p style={{ color: '#718096', margin: 0, fontSize: '0.9rem' }}>
{post.body?.substring(0, 100)}...
</p>
</div>
<button
onClick={() => deletePost(post.id)}
style={{ color: '#e53e3e', background: 'none', border: 'none', cursor: 'pointer' }}
>
刪除
</button>
</div>
))
)}
</div>
);
}
export default PostManager;
18.3 自訂 useFetch Hook
import { useState, useEffect, useCallback } from 'react';
function useFetch(url, options = {}) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const fetchData = useCallback(async () => {
const controller = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const json = await response.json();
setData(json);
} catch (err) {
if (err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
return () => controller.abort();
}, [url]);
useEffect(() => {
fetchData();
}, [fetchData]);
return { data, loading, error, refetch: fetchData };
}
// 使用
function App() {
const { data: users, loading, error, refetch } = useFetch(
'https://jsonplaceholder.typicode.com/users'
);
if (loading) return <p>載入中...</p>;
if (error) return <p>錯誤:{error} <button onClick={refetch}>重試</button></p>;
return (
<div>
<button onClick={refetch}>重新載入</button>
<ul>
{users?.map((u) => <li key={u.id}>{u.name}</li>)}
</ul>
</div>
);
}
18.4 載入狀態 UI 模式
Skeleton 載入畫面
function SkeletonCard() {
const skeletonStyle = {
backgroundColor: '#e2e8f0',
borderRadius: '4px',
animation: 'pulse 1.5s infinite',
};
return (
<div style={{
padding: '20px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
marginBottom: '12px',
}}>
<div style={{ ...skeletonStyle, width: '60%', height: '20px', marginBottom: '12px' }} />
<div style={{ ...skeletonStyle, width: '100%', height: '14px', marginBottom: '8px' }} />
<div style={{ ...skeletonStyle, width: '80%', height: '14px' }} />
<style>{`
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
`}</style>
</div>
);
}
function PostListWithSkeleton() {
const { data: posts, loading } = useFetch(
'https://jsonplaceholder.typicode.com/posts?_limit=5'
);
return (
<div style={{ maxWidth: '600px', margin: '20px auto' }}>
<h2>文章列表</h2>
{loading ? (
<>
<SkeletonCard />
<SkeletonCard />
<SkeletonCard />
</>
) : (
posts?.map((post) => (
<div key={post.id} style={{
padding: '20px',
border: '1px solid #e2e8f0',
borderRadius: '8px',
marginBottom: '12px',
}}>
<h3>{post.title}</h3>
<p style={{ color: '#718096' }}>{post.body}</p>
</div>
))
)}
</div>
);
}
18.5 練習題
練習 1:建立一個 GitHub 使用者搜尋器
- 使用 GitHub API:
https://api.github.com/search/users?q={query}
- 包含搜尋、載入狀態、錯誤處理
- 顯示頭像、使用者名稱、個人頁面連結
練習 2:建立一個 CRUD 待辦事項(with API)
- 使用 JSONPlaceholder 的
/todos 端點
- 實作完整的新增、讀取、更新、刪除功能
- 包含樂觀更新(Optimistic Update)
本課重點回顧
- 使用 AbortController 取消請求,防止記憶體洩漏
- 統一的 loading / error / data 三態管理
- 使用 axios 實例 + 攔截器管理 API 配置
- 封裝成 useFetch 自訂 Hook 複用邏輯
- 使用 Skeleton 提升載入體驗