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)

本課重點回顧

  1. 使用 AbortController 取消請求,防止記憶體洩漏
  2. 統一的 loading / error / data 三態管理
  3. 使用 axios 實例 + 攔截器管理 API 配置
  4. 封裝成 useFetch 自訂 Hook 複用邏輯
  5. 使用 Skeleton 提升載入體驗