🤞 完整範例:Github Repo │ 👻 Live Demo:點我看 demo
專案被擴充得越來越大時,發現自己埋的坑越來越多。
為了讓示範更清晰,本文先略過 TypeScript。
前期 API 的呼叫方式
前期開發 React 專案時,我習慣將 API 請求統一集中在資料夾 /api 下,由每個 component 單獨呼叫使用。
專案規模擴大後出現的架構問題
隨著專案的規模愈來愈大,功能愈來愈複雜時,同一個 API 在多處被呼叫,我開始碰到以下問題:
- 每個 component 需各自維護 loading / error / data 等狀態,導致狀態管理分散、代碼冗餘
- 同一支 API 被多處重複呼叫,導致請求重複,造成不必要的 Network、後端壓力及 UX 延遲
- 資料 cache 的缺失,不同 component 中拿到的資料內容可能出現不一致,需額外處理
- 資料轉換邏輯分散,難以維護
- 錯誤處理容易不一致,難以維護,使用者體驗不穩定
- 跨 component 的資料共用困難,狀態同步成本高,可能需要不斷地通過 props 傳遞或使用 store
⋯⋯
架構的重構:API / Service / Hook / Component
因此,我決定重新設計資料存取的架構。將原本由 component 直接呼叫 API 並處理所有相關邏輯的做法,依責任拆分為 API 層、Service 層、Hooks 層與 Component 層。透過職責分離,讓每一層專注於單一責任,進而提升整體的可讀性、可維護性與可擴充性。
- API 層 → 專注於處理實際的 HTTP 請求與共用設定,例如 base URL、headers、token、以及通用的錯誤攔截等。
- Service 層 → 負責封裝業務邏輯與資料轉換,將 API 回傳的資料整理為前端實際需要的通用的資料結構。
- Hook 層(我使用 TanStack Query 的 useQuery / useMutation)→ 負責資料的獲取與 server state 的管理,包含快取、重新請求、錯誤處理與狀態管理。
- Component 層 → 只關注 UI 呈現與互動邏輯,不再直接處理 API 呼叫與資料轉換。
API 層
API 層的核心原則:
- 專注於資料請求:所有 HTTP 請求與共用設定集中在這裡,不做任何 UI 或業務邏輯處理。
- 統一設定:baseURL、headers、timeout 與 interceptor 全部集中,方便修改與管理。
- 可測試性與可替換性:上層 Service / Hook 可以獨立測試,未來若要替換 axios 或換 HTTP client,只需修改這一層。
我將所有 API 的共用設定與具體 API function 分開。使用 axios 進行示範。先建立一個共用的 API 配置,集中管理所有 HTTP 請求的基礎設定 /lib/axiosClient.js:
import axios from 'axios';
const axiosClient = axios.create({
baseURL: 'https://api.example.com/v1',
headers: {
'Content-Type': 'application/json',
},
timeout: 10 * 1000,
});
axiosClient.interceptors.request.use(
async (config) => {
return config;
},
(err) => {
return Promise.reject(err);
}
);
export default axiosClient;
具體的 API function /apis/classApi.js:
import axiosClient from '@/lib/axiosClient';
// 取得班級列表
export const fetchClasses = async () => {
const { data } = await axiosClient.get('/classes');
return data;
};
/apis/studentApi.js:
import axiosClient from '@/lib/axiosClient';
// 取得學生列表
export const fetchStudents = async () => {
const { data } = await axiosClient.get('/students');
return data;
};
// 刪除學生
export const deleteStudent = async (studentId) => {
const { data } = await axiosClient.delete(`/students/${studentId}`);
return data;
};
Service 層
Service 層介於 API 層與 Hook 層/Component 層之間:
- 封裝業務邏輯:把原始 API 資料轉換成符合需求的格式,例如過濾、排序、計算等。
- 集中錯誤處理:可在此統一處理 API 錯誤、網路異常或回傳格式問題。
- 提高可維護性:當 API 回傳結構改變時,只需修改 Service 層,Component / Hook 不受影響。
- 整合多個 API:可在 Service 層將多個 API 結果組合、計算或轉換成複合資料供上層使用。
/services/classService.js
import { fetchClasses } from '@/apis/classApi';
export const getClassMap = async () => {
try {
const classesData = await fetchClasses();
if (!classesData || !Array.isArray(classesData.classes)) {
console.warn('班級資料格式錯誤,返回空對照表');
return {};
}
return Object.fromEntries(
classesData.classes.map(c => [c.id, `${c.grade}年${c.class}班`])
);
} catch (error) {
return {};
}
};
/services/studentService.js
import { fetchStudents, deleteStudent as apiDeleteStudent } from '@/apis/studentApi';
import { fetchClasses } from '@/apis/classApi';
import { getClassMap } from './classService';
import { BusinessError } from '@/lib/errors';
// 一個 service 呼叫 API 並使用另一個 service
export const getStudents = async () => {
try {
const [studentsData, classMap] = await Promise.all([
fetchStudents(),
getClassMap(),
]);
return studentsData.students.map(s => ({
...s,
classFullName: classMap[s.classId] || 'Unknown',
}));
} catch (error) {
// 在取得學生列表有問題時,可在這裡統一降級處理
throw error;
}
};
// 刪除學生
export const deleteStudent = async (studentId) => {
try {
// 驗證 studentId,統一在 studentService 中拋出刪除時找不到 studentId 的錯誤
if (!studentId) {
throw new BusinessError('學生 ID 不可為空', 'INVALID_STUDENT_ID');
}
await apiDeleteStudent(studentId);
} catch (error) {
throw error;
}
};
Hook 層
Hook 層介於 Service 層與 Component 層之間,它的核心目的就是:把資料獲取與狀態管理邏輯封裝起來,讓 Component 可以專注於 UI:
- 狀態自動化:管理 loading / error / stale 等的狀態,例如 isLoading、isError、data、refetch,Component 不需要自己寫 useEffect + useState
- 快取與重新發送:透過成熟的套件(如 TanStack Query),可以實現資料快取,減少重複請求。
- 封裝資料獲取:直接呼叫 Service 層的 function,並返回 ready-to-use 的資料,可以把多個 Service 組合的結果封裝成單一 Hook,Component 只需要拿資料就好。
/hooks/useUsers.js
import { useQuery } from '@tanstack/react-query';
import { getStudents } from '@/services/studentService';
export const useStudents = () => {
return useQuery({
queryKey: ['students'], // Query 的唯一鍵值,用來辨識和快取這筆 'students' 資料
queryFn: getStudents,
staleTime: 1000 * 60,
});
};
/hooks/useDeleteUser.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { deleteStudent } from '@/services/studentService';
export const useDeleteStudent = (options = {}) => {
const {
onSuccess: customOnSuccess,
} = options;
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteStudent,
// 刪除成功後重新獲取學生列表
onSuccess: (data, studentId, context) => {
queryClient.invalidateQueries(['students']);
customOnSuccess?.(data, studentId, context);
},
});
};
Component 層
Component 層是最接近 UI 的一層,它的核心職責就是:呈現使用者介面,而不需要知道資料從哪裡來,也不處理複雜的資料邏輯。
簡單說,Component 層專注於「畫面」,把資料邏輯交給 Hook / Service 層處理:
- 只負責 UI 的呈現:不直接呼叫 API,也不做資料過濾或組合,所有資料都透過 Hook 拿到 ready-to-use 的狀態。
- 使用 Hook 層取得資料:React Query hooks、Custom hooks 等都在這裡使用,包含取得的資料和取得資料的狀態等。
- 回傳使用者互動事件:例如按鈕觸發 mutation(新增、刪除、更新);Component 不處理資料細節,只呼叫 hook 提供的 mutation。
/components/StudentComponent.jsx
import { useState, useEffect } from 'react';
import { useStudents } from '@/hooks/useStudents';
import { useDeleteStudent } from '@/hooks/useDeleteStudent';
import { getUserFriendlyMessage } from '@/lib/errors';
function StudentDashboard() {
const [successMessage, setSuccessMessage] = useState('');
// 使用 hook 並取得錯誤狀態
const { data: students, isLoading, isError, error, refetch } = useStudents();
const {
mutate: deleteStudent,
isPending: isDeleting,
isError: isDeleteError,
error: deleteError,
} = useDeleteStudent({
onSuccess: () => {
setSuccessMessage('刪除成功!');
}
});
// 載入中狀態
if (isLoading) {
return <div>載入中...</div>;
}
// 錯誤狀態 - 在 Component 層處理錯誤顯示
if (isError) {
return (
<div>
<div>
<h3>無法載入學生資料</h3>
<p>{getUserFriendlyMessage(error)}</p>
<button onClick={() => refetch()}>
重試
</button>
</div>
</div>
);
}
return (
<div>
<h2>學生列表</h2>
{/* 刪除成功提示 */}
{successMessage && (
<div>{successMessage}</div>
)}
{/* 刪除錯誤提示 - inline 顯示 */}
{isDeleteError && (
<div>{getUserFriendlyMessage(deleteError)}</div>
)}
<ul>
<li>
<span>姓名</span>
<span>性別</span>
<span>班級</span>
<span>生日</span>
<span></span>
</li>
{students.length === 0 ? (
<li>目前沒有學生資料</li>
) : (
students.map((s) => (
<li key={s.id}>
<span>{s.name}</span>
<span>{s.gender}</span>
<span>{s.classFullName}</span>
<span>{s.birthday}</span>
<button
onClick={() => deleteStudent(s.id)}
disabled={isDeleting}
>
{isDeleting ? '刪除中...' : '刪除'}
</button>
</li>
))
)}
</ul>
</div>
);
}
export default StudentDashboard;
🚀 完整範例 & Demo
本文的所有概念、API / Service / Hook / Component 層分層策略,都已在範例專案中經過補充,完整呈現(但還是沒有加入 typescript 喔嘿)。
🤞 完整範例:Github Repo │ 👻 Live Demo:點我看 demo