---
# System prepended metadata

title: API 進階整合應用，Axios 攔截器設定與處理認證及授權等

---

# API 進階整合應用，Axios 攔截器設定與處理認證及授權等

## 專案啟動
### 專案技術棧介紹

#### 核心框架

- **Next.js 16.0.0**: 全端 React 框架
  - 檔案系統路由
  - 內建 API 路由
  - 靜態生成 (SSG) 和伺服器端渲染 (SSR)

#### 型別安全

- **TypeScript 5**: 靜態型別檢查
  - 編譯時錯誤檢測
  - 更好的 IDE 支援
  - 重構安全性

#### 狀態管理

- **TanStack React Query 5.90.5**: 伺服器狀態管理
  - 自動快取和背景更新
  - 錯誤處理和重試

#### 表單處理

- **React Hook Form 7.65.0**: 高效能表單庫
  - 非受控組件
  - 內建驗證
  - 最小重新渲染

#### HTTP 客戶端

- **Axios 1.12.2**: Promise 基礎的 HTTP 客戶端
  - 請求/回應攔截器
  - 自動 JSON 轉換
  - 錯誤處理

---

### 安裝方式 (課程上請下載範例專案)

```bash
# 建立新專案
npx create-next-app@latest my-app --yes
cd my-app
npm run dev
```

[範例專案](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/base-api-project)

#### 課程使用配置
```
✔ What is your project named? … my-app
✔ Would you like to use the recommended Next.js defaults? › No, customize settings
✔ Would you like to use TypeScript? … Yes
✔ Which linter would you like to use? › ESLint
✔ Would you like to use React Compiler? … Yes
✔ Would you like to use Tailwind CSS? … No
✔ Would you like your code inside a `src/` directory? … Yes
✔ Would you like to use App Router? (recommended) … No
✔ Would you like to use Turbopack? (recommended) … No
✔ Would you like to customize the import alias (`@/*` by default)? … Yes
✔ What import alias would you like configured? … @/*
```

---

### 專案結構解析

```
src/
├── pages/                    # Next.js 頁面路由
│   ├── _app.tsx             # 應用程式根組件
│   ├── _document.tsx        # HTML 文檔結構
│   └── index.tsx            # 首頁 (/)
├── helper/                  # 工具函數和配置
│   └── react-query/         # React Query 配置
│       └── index.ts         # QueryClient 設定
└── ...                      # 其他源碼目錄
```


#### 關鍵檔案說明

**`src/pages/_app.tsx`** - 應用程式根組件

```typescript
import queryClient from '@/helper/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import type { AppProps } from 'next/app';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <Component {...pageProps} />
    </QueryClientProvider>
  );
}
```

**`src/helper/react-query/index.ts`** - React Query 配置

```typescript
import { QueryClient } from '@tanstack/react-query';

const queryClient = new QueryClient();

export default queryClient;
```

---


### Next.js 配置

**`next.config.ts`** - Next.js 設定檔

```typescript
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  reactCompiler: true, // 啟用 React Compiler (實驗性)
  reactStrictMode: true, // 啟用嚴格模式
};

export default nextConfig;
```

#### 配置說明

- **reactCompiler**: 啟用實驗性的 React 編譯器，自動優化組件
- **reactStrictMode**: 啟用嚴格模式，幫助發現潛在問題

---

### 依賴套件分析

#### 生產依賴 (dependencies)

```json
{
  "@tanstack/react-query": "^5.90.5", // 伺服器狀態管理
  "axios": "^1.12.2", // HTTP 客戶端
  "next": "16.0.0", // Next.js 框架
  "react": "19.2.0", // React 核心
  "react-dom": "19.2.0", // React DOM
  "react-hook-form": "^7.65.0" // 表單處理
}
```

#### 開發依賴 (devDependencies)

```json
{
  "@types/node": "^20", // Node.js 型別定義
  "@types/react": "^19", // React 型別定義
  "@types/react-dom": "^19", // React DOM 型別定義
  "babel-plugin-react-compiler": "1.0.0", // React 編譯器
  "eslint": "^9", // 程式碼檢查
  "eslint-config-next": "16.0.0", // Next.js ESLint 配置
  "typescript": "^5" // TypeScript 編譯器
}
```

---

### 開發環境設定

#### 必要工具

- **Node.js 18+**: JavaScript 執行環境
- **VS Code**: 推薦編輯器
- **Git**: 版本控制

#### 推薦 VS Code 擴充功能

- TypeScript Importer
- ES7+ React/Redux/React-Native snippets
- Prettier - Code formatter
- ESLint

### 3.2 專案啟動流程

```bash
# 1. 安裝依賴
npm install

# 2. 啟動開發伺服器
npm run dev

# 3. 開啟瀏覽器
# http://localhost:3000
```

#### 可用指令

```json
{
  "scripts": {
    "dev": "next dev --webpack", // 開發模式
    "build": "next build --webpack", // 建置專案
    "start": "next start", // 生產模式
    "lint": "eslint" // 程式碼檢查
  }
}
```

<br/>

## Context Hook — 全域狀態與共用邏輯管理

### 1. 為什麼需要 Context？

在 React 應用中，當組件層級不斷變深時，**props 傳遞**會造成代碼維護困難。

#### ❌ 問題示例：Props drilling（層層傳遞）

```tsx
// App → Layout → Toolbar → UserInfo
function App() {
  const user = { name: '江星誌', role: '系統分析師' };
  return <Layout user={user} />;
}

function Layout({ user }: { user: any }) {
  return <Toolbar user={user} />;
}

function Toolbar({ user }: { user: any }) {
  return <UserInfo user={user} />;
}

function UserInfo({ user }: { user: any }) {
  return <p>使用者：{user.name}</p>;
}
```

> 缺點：
>
> * 所有中間層都要傳遞 `user`
> * 當結構變動時，維護成本高

---

### 2. 使用 Context 解決全域資料共用

React 提供 `createContext` 與 `useContext`，讓你在組件樹中共享狀態，而不需層層傳遞。

#### ✅ 範例：建立 UserContext

**`src/context/UserContext.tsx`**

```tsx
import { createContext, useContext, useState, ReactNode } from 'react';

// 定義型別
type User = { name: string; role: string };
type UserContextType = {
  user: User | null;
  setUser: (user: User | null) => void;
};

// 建立 Context
const UserContext = createContext<UserContextType | undefined>(undefined);

// 提供者組件
export const UserProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<User | null>({
    name: '江星誌',
    role: '系統分析師',
  });

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
};

// 封裝成自訂 Hook
export const useUser = () => {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser 必須在 UserProvider 內使用');
  }
  return context;
};
```

---

### 3. 使用 Context 在應用中共用狀態

**`src/pages/_app.tsx`**

```tsx
import { QueryClientProvider } from '@tanstack/react-query';
import queryClient from '@/helper/react-query';
import { UserProvider } from '@/context/UserContext';
import type { AppProps } from 'next/app';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProvider>
        <Component {...pageProps} />
      </UserProvider>
    </QueryClientProvider>
  );
}
```

**`src/pages/index.tsx`**

```tsx
import { useUser } from '@/context/UserContext';

export default function Home() {
  const { user, setUser } = useUser();

  return (
    <main style={{ padding: '2rem' }}>
      <h1>🎉 Context Hook 範例</h1>
      <p>目前使用者：{user?.name}</p>

      <button
        onClick={() => setUser({ name: '其他人', role: '角色' })}
      >
        切換使用者
      </button>
    </main>
  );
}
```

✅ **成果**：

* 所有頁面都能共用 `user`
* 不需再層層傳遞 props

[當前專案範本](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/context-hook)

---

### 4. 自訂 Hook 的設計邏輯

自訂 Hook 通常負責：

* 封裝重複邏輯
* 整合外部工具（如 React Query、Axios）
* 提供型別安全與可重用的 API

#### 🎯 範例：封裝登入狀態管理

**`src/hooks/useAuth.ts`**

```tsx
import axios from 'axios';
import { useState } from 'react';

export const useAuth = () => {
  const [access, setAccess] = useState<string | null>(null);
  const [refresh, setRefresh] = useState<string | null>(null);

  const login = async (username: string, password: string) => {
    const res = await axios.post('http://localhost:8000/api/auth/jwt/create', {
      username,
      password,
    });
    setAccess(res.data.access);
    setRefresh(res.data.refresh);
  };

  const logout = () => {
    setAccess(null);
    setRefresh(null);
  };

  return { access, refresh, login, logout };
};
```

接著可將此 Hook 與 Context 整合：

**`src/context/AuthContext.tsx`**

```tsx
import { createContext, useContext, ReactNode } from 'react';
import { useAuth } from '@/hooks/useAuth';

const AuthContext = createContext<ReturnType<typeof useAuth> | undefined>(
  undefined
);

export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const auth = useAuth();
  return (
    <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>
  );
};

export const useAuthContext = () => {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error('useAuthContext 必須在 AuthProvider 中使用');
  return ctx;
};
```

然後將 Provider 也設置到 _app 的檔案上：

**`src/pages/_app.tsx`**

```tsx
import { AuthProvider } from '@/context/AuthContext';
import { UserProvider } from '@/context/UserContext';
import queryClient from '@/helper/react-query';
import { QueryClientProvider } from '@tanstack/react-query';
import type { AppProps } from 'next/app';

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <UserProvider>
        <AuthProvider>
          <Component {...pageProps} />
        </AuthProvider>
      </UserProvider>
    </QueryClientProvider>
  );
}
```

最後將新的 Hook 操作方式引用到首頁上：

**`src/pages/index.tsx`**

```tsx
import { useAuth } from '@/hooks/useAuth';

export default function Home() {
  const { login, access, refresh, logout } = useAuth();

  return (
    <main style={{ padding: '2rem' }}>
      {access ? <p>Logged in</p> : <p>Logged out</p>}
      <button onClick={() => login('admin', 'admin')}>Login</button>
      <button onClick={() => logout()}>Logout</button>
      <p>Access: {access}</p>
      <p>Refresh: {refresh}</p>
    </main>
  );
}
```

[當前專案範本](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/custom-hook)

---


### 5. Context + React Query 實務整合範例

許多應用會在登入後需要帶 Token 進行 API 請求。
這時我們可以透過 Context 結合 **Axios 攔截器** 來達成。

**`src/helper/api-client/index.ts`**

```tsx
import axios from 'axios';

export const apiClient = axios.create({
  baseURL: 'http://localhost:8000/api',
});

// 攔截器設定
apiClient.interceptors.request.use((config) => {
  const access = localStorage.getItem('access');
  if (access) config.headers.Authorization = `Bearer ${access}`;
  return config;
});
```

建立使用 react query 的 api 敲打方式：

**`src/servers/auth.ts`**
```tsx
import { apiClient } from '@/helper/api-client';
import { useMutation } from '@tanstack/react-query';

type AuthJwtCreateParams = {
  username: string;
  password: string;
};

export const useAuthJwtCreate = () => {
  return useMutation({
    mutationFn: async ({ username, password }: AuthJwtCreateParams) => {
      const { data } = await apiClient.post('/auth/jwt/create', {
        username,
        password,
      });
      return data;
    },
  });
};
```

更新原先的 useAuth hook 功能

**`src/hooks/useAuth.ts`**
```tsx
import { useAuthJwtCreate } from '@/servers/auth';
import { useState } from 'react';

export const useAuth = () => {
  const { mutate: authJwtCreate } = useAuthJwtCreate();

  const [access, setAccess] = useState<string | null>(null);
  const [refresh, setRefresh] = useState<string | null>(null);

  const login = async (username: string, password: string) => {
    authJwtCreate(
      { username, password },
      {
        onSuccess: (data) => {
          setAccess(data.access);
          setRefresh(data.refresh);
          localStorage.setItem('access', data.access);
        },
        onError: (error) => {
          console.error(error);
        },
      },
    );
  };

  const logout = () => {
    setAccess(null);
    setRefresh(null);
  };

  return { access, refresh, login, logout };
};
```


[當前專案範本](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/axios-client)


---

### 6. 小結

| 概念                          | 說明                      |
| --------------------------- | ----------------------- |
| **Context**                 | 全域狀態共用，取代 props 傳遞      |
| **自訂 Hook**                 | 封裝邏輯與狀態，提升重用性           |
| **React Query + Axios 攔截器** | 實現 Token 驗證與自動帶入 Header |
| **Provider 嵌套**             | 可在 _app.tsx 中統一註冊全域狀態   |

<br/>

## TODO LIST 範例製作


[範例專案](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/todo-list)

### 1. 設置表單相關組件
本章節的目標是建立一個可重用的表單組件，利用 `react-hook-form` 處理驗證、控制輸入，並支援多種欄位型別（text、number、select、checkbox、textarea 等）。
#### 1.1 設置表單欄位型別

📄 檔案位置：`src/models/form-field.ts`

這個型別用來定義**表單欄位的結構**，以便在不同表單中共用。
每個欄位都會有：

* `label`：欄位標題
* `name`：欄位名稱（對應到資料模型中的 key）
* `type`：欄位類型
* `required`：是否必填
* `options`：若為下拉選單，則提供可選項目
```tsx
export type FormField = {
  label: string;
  name: string;
  type: 'text' | 'number' | 'email' | 'boolean' | 'select' | 'textarea';
  required?: boolean;
  options?: { label: string; value: string }[];
};
```

#### 1.2 建立表單欄位切換

📄 檔案位置：`src/components/Fields/index.tsx`

這個組件用於根據 `FormField.type` 自動渲染不同輸入元件。
我們透過 `switch` 判斷欄位型別後，返回對應的 `<input>`、`<select>` 或 `<textarea>`。
每個欄位也會整合 `react-hook-form` 的 `register()` 與錯誤訊息顯示。

```tsx
import { FormField } from '@/models/form-field';
import { UseFormReturn } from 'react-hook-form';

type Props = {
  field: FormField;
  methods: UseFormReturn<Record<string, unknown>>;
};
export const TextField = ({ field, methods }: Props) => {
  const { label, name, type } = field;
  const {
    register,
    formState: { errors },
  } = methods;

  switch (type) {
    case 'text':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='text'
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'number':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='number'
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'email':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='email'
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'boolean':
      return (
        <section>
          <label htmlFor={name}>
            <input
              type='checkbox'
              {...register(name, { required: field.required })}
            />
            {label}
          </label>
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'select':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <select {...register(name, { required: field.required })}>
            <option value=''>請選擇 {label}</option>
            {field.options?.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'textarea':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <textarea
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
        </section>
      );
    default:
      return null;
  }
};
```

💡 **補充說明：**

* 使用 `errors[name]` 即可即時顯示錯誤訊息。
* `placeholder` 透過模板字串自動生成「請輸入 + 欄位名」。
* 使用 `<section>` 包裝欄位，方便後續 CSS 版面控制。


#### 1.3 建立表單組件
📄 檔案位置：`src/components/Form/index.tsx`

這裡是整個表單的外層容器，
負責：

* 初始化 `useForm()`
* 提供上下文給子欄位 (`FormProvider`)
* 負責表單送出事件 `onSubmit`
* 支援資料重設（例如編輯時）

```tsx
import { FormField } from '@/models/form-field';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { TextField } from '../Fields';

type Props = {
  fields: FormField[];
  onSubmit: (data: Record<string, unknown>) => void;
  resetData?: Record<string, unknown>;
};

export const Form = ({ fields, onSubmit, resetData }: Props) => {
  const methods = useForm<Record<string, unknown>>();

  useEffect(() => {
    if (resetData) {
      fields.forEach((field) => {
        methods.setValue(field.name, resetData[field.name]);
      });
    }
  }, [fields, resetData, methods]);

  const handleSubmit = (data: Record<string, unknown>) => {
    onSubmit(data);
  };

  return (
    <FormProvider {...methods}>
      <form
        onSubmit={methods.handleSubmit(handleSubmit)}
        style={{ border: '1px solid #000', padding: '1rem' }}
      >
        <article>
          {fields.map((field) => (
            <TextField key={field.name} field={field} methods={methods} />
          ))}
        </article>
        <footer>
          <button type='submit'>Submit</button>
        </footer>
      </form>
    </FormProvider>
  );
};
```

🧠 **補充重點：**

* `FormProvider` 提供上下文，使所有子元件都能使用同一個表單狀態。
* `useEffect` 內的 `resetData` 可以實現「編輯時預填資料」。
* `methods.handleSubmit` 自動包含驗證與資料處理。



---

### 2. 設置其餘相關 Model

這部分建立專案需要的**基本資料結構**，例如 API 回傳格式、Todo 任務欄位、使用者登入參數等。

#### 2.1 建立 api 相關 Model
📄 檔案位置：`src/models/api.ts`

這裡是後端通用回傳格式定義。
`ApiPaginatedResponse` 對應 Django REST Framework 預設分頁結構。

```ts=
export type ApiPaginatedResponse<T> = {
  results: T[];
  count: number;
  next: string | null;
  previous: string | null;
};

export type BaseApiResponse<T extends object> = T & {
  created_at: string;
  id: number;
  updated_at: string;
  owner: {
    email: string;
    id: number;
    username: string;
  };
};
```

#### 2.2 建立 todo 相關 Model

📄 檔案位置：`src/models/todo.ts`

定義任務（task）、專案（project）、標籤（tag）等資料結構。

```ts=
export enum Priority {
  LOW = 'Low',
  MEDIUM = 'Medium',
  HIGH = 'High',
  URGENT = 'Urgent',
}

export type TodoProjectType = {
  name: string;
  is_public: boolean;
};

export type TodoTagType = {
  name: string;
};

export type TodoTaskType = {
  title: string;
  description: string;
  priority: Priority;
  is_completed: boolean;
  due_date: Date;
  project_id: number;
  tag_ids: number[];
};
```

💬 **補充：**

* `Priority` 使用 `enum` 管理優先順序。
* `TodoTaskType` 與 `project_id`、`tag_ids` 維持資料關聯性。


#### 2.3 建立 Auth 相關 Model

📄 檔案位置：`src/models/auth.ts`

簡單定義 JWT 登入使用的 request/response 型別。

```ts=
export type AuthJwtCreateParams = {
  username: string;
  password: string;
};

export type AuthJwtCreateResponse = {
  access: string;
  refresh: string;
};
```


#### 2.4 建立表單資料
📄 檔案位置：`src/fixtures/todo-field-config.ts`

```tsx=
import { FormField } from '@/models/form-field';
import { Priority } from '@/models/todo';

export const todoProjectFieldConfig: FormField[] = [
  {
    label: '名稱',
    name: 'name',
    type: 'text',
    required: true,
  },
  {
    label: '是否公開',
    name: 'is_public',
    type: 'boolean',
  },
];

export const todoTagFieldConfig: FormField[] = [
  {
    label: '名稱',
    name: 'name',
    type: 'text',
    required: true,
  },
];

export const todoTaskFieldConfig: FormField[] = [
  {
    label: '標題',
    name: 'title',
    type: 'text',
    required: true,
  },
  {
    label: '描述',
    name: 'description',
    type: 'textarea',
    required: true,
  },
  {
    label: '優先級',
    name: 'priority',
    type: 'select',
    required: true,
    options: [
      { label: 'Low', value: Priority.LOW },
      { label: 'Medium', value: Priority.MEDIUM },
      { label: 'High', value: Priority.HIGH },
      { label: 'Urgent', value: Priority.URGENT },
    ],
  },
  {
    label: '是否完成',
    name: 'is_completed',
    type: 'boolean',
  },
  {
    label: '截止日期',
    name: 'due_date',
    type: 'text',
  },
  {
    label: '專案ID',
    name: 'project_id',
    type: 'number',
    required: true,
  },
];
```


---


### 3. 撰寫 servers API 操作函式

這裡是使用 **React Query + Axios** 的核心：
將每個資料模型的 CRUD 操作包裝成可重用的 hook。

#### 3.1 設置 Auth 相關 API

📄 檔案位置：`src/servers/auth.ts`

登入時使用 `/auth/jwt/create`，獲取 access/refresh token。

```ts=
import { apiClient } from '@/helper/api-client';
import { AuthJwtCreateParams, AuthJwtCreateResponse } from '@/models/auth';
import { useMutation } from '@tanstack/react-query';

export const useAuthJwtCreate = () => {
  return useMutation({
    mutationFn: async ({ username, password }: AuthJwtCreateParams) => {
      const { data } = await apiClient.post('/auth/jwt/create', {
        username,
        password,
      });
      return data as AuthJwtCreateResponse;
    },
  });
};
```

#### 3.2 設置 todo project 相關 API

📄 檔案位置：`src/servers/todo-project.ts`

提供對「專案」的 CRUD 操作：

* List：取得所有專案
* Retrieve：查看單筆
* Create / Update / Delete

```ts=
import { apiClient } from '@/helper/api-client';
import { ApiPaginatedResponse, BaseApiResponse } from '@/models/api';
import { TodoProjectType } from '@/models/todo';
import { useMutation, useQuery } from '@tanstack/react-query';

const todoProjectListQueryKey = 'todoProjectList';

export const useTodoProjectList = () => {
  return useQuery({
    queryKey: [todoProjectListQueryKey],
    queryFn: async () => {
      const { data } = await apiClient.get('/v1/todo/projects');
      return data as ApiPaginatedResponse<BaseApiResponse<TodoProjectType>>;
    },
  });
};

export const useTodoProjectRetrieve = (projectId: string) => {
  return useQuery({
    queryKey: [todoProjectListQueryKey, projectId],
    queryFn: async () => {
      const { data } = await apiClient.get(`/v1/todo/projects/${projectId}`);
      return data as BaseApiResponse<TodoProjectType>;
    },
    enabled: !!projectId,
  });
};

export const useTodoProjectCreate = () => {
  return useMutation({
    mutationFn: async (project: TodoProjectType) => {
      const { data } = await apiClient.post('/v1/todo/projects', project);
      return data;
    },
  });
};

export const useTodoProjectUpdate = () => {
  return useMutation({
    mutationFn: async (project: TodoProjectType & { id: string }) => {
      const { data } = await apiClient.put(
        `/v1/todo/projects/${project.id}`,
        project,
      );
      return data;
    },
  });
};

export const useTodoProjectDelete = () => {
  return useMutation({
    mutationFn: async (projectId: string) => {
      const { data } = await apiClient.delete(`/v1/todo/projects/${projectId}`);
      return data;
    },
  });
};
```

🧩 **補充：**

* 所有 API 均透過 React Query 管理快取。
* `queryKey` 用於自動重新整理（refetch）。

---


### 4. 修改 useAuth 內部函式

📄 檔案位置：`src/hooks/useAuth.ts`

這個 Hook 封裝登入／登出邏輯，統一管理 JWT Token。
```ts=
import { useAuthJwtCreate } from '@/servers/auth';
import { useState } from 'react';

export const useAuth = () => {
  const { mutate: authJwtCreate } = useAuthJwtCreate();

  const [access, setAccess] = useState<string | null>(null);
  const [refresh, setRefresh] = useState<string | null>(null);

  const login = async (username: string, password: string) => {
    authJwtCreate(
      { username, password },
      {
        onSuccess: (data) => {
          setAccess(data.access);
          setRefresh(data.refresh);
          localStorage.setItem('access', data.access);
        },
        onError: (error) => {
          console.error(error);
        },
      },
    );
  };

  const logout = () => {
    setAccess(null);
    setRefresh(null);
  };

  return { access, refresh, login, logout };
};
```

💡 **重點補充：**

* 使用 React Query 的 `onSuccess` 事件接收登入回傳。
* Token 存在 `localStorage`，刷新頁面後仍可保留。


---


### 5. 製作 Todo

這裡整合前面所有模組，完成「專案管理」功能。
#### 5.1 製作 Todo 組件
📄 檔案位置：`src/components/Todo/index.tsx`

此組件負責：

* 顯示所有 Todo Project
* 新增、編輯、刪除功能
* 切換「新增表單」與「編輯表單」

```tsx=
import { todoProjectFieldConfig } from '@/fixtures/todo-field-config';
import { TodoProjectType } from '@/models/todo';
import {
  useTodoProjectCreate,
  useTodoProjectDelete,
  useTodoProjectList,
  useTodoProjectRetrieve,
  useTodoProjectUpdate,
} from '@/servers/todo-project';
import { useState } from 'react';
import { Form } from '../Form';

const TodoProject = () => {
  const [currentId, setCurrentId] = useState<number | null>(null);

  const {
    data: todoProjectList,
    refetch: refetchTodoProjectList,
    isFetching: isFetchingTodoProjectList,
    isPending: isPendingTodoProjectList,
  } = useTodoProjectList();
  const { data: todoProjectData } = useTodoProjectRetrieve(
    currentId?.toString() ?? '',
  );
  const { mutate: createTodoProject } = useTodoProjectCreate();
  const { mutate: updateTodoProject } = useTodoProjectUpdate();
  const { mutate: deleteTodoProject } = useTodoProjectDelete();

  const handleCreateTodoProject = (data: TodoProjectType) => {
    createTodoProject(data, {
      onSuccess: () => {
        refetchTodoProjectList();
      },
    });
  };

  const handleUpdateTodoProject = (data: TodoProjectType) => {
    updateTodoProject(
      { ...data, id: currentId?.toString() ?? '' },
      {
        onSuccess: () => {
          refetchTodoProjectList();
          setCurrentId(null);
        },
      },
    );
  };

  const handleDeleteTodoProject = (id: number) => {
    deleteTodoProject(id.toString(), {
      onSuccess: () => {
        refetchTodoProjectList();
      },
    });
  };

  return (
    <div>
      {isPendingTodoProjectList || isFetchingTodoProjectList ? (
        <div>Loading...</div>
      ) : (
        <>
          <h1>Todo Project 新增表單</h1>
          <Form
            fields={todoProjectFieldConfig}
            onSubmit={(data) =>
              handleCreateTodoProject(data as TodoProjectType)
            }
          />

          {currentId && (
            <>
              <h1>
                Todo Project 編輯表單{' '}
                <button onClick={() => setCurrentId(null)}>取消編輯</button>
              </h1>
              <Form
                fields={todoProjectFieldConfig}
                onSubmit={(data) =>
                  handleUpdateTodoProject(data as TodoProjectType)
                }
                resetData={todoProjectData}
              />
            </>
          )}
        </>
      )}

      {todoProjectList && (
        <ul>
          {todoProjectList?.results?.map((project) => (
            <li key={project.id}>
              <div>
                {project.name} {project.is_public ? '公開' : '私密'}
              </div>
              <button onClick={() => setCurrentId(project.id)}>編輯欄位</button>
              <button onClick={() => handleDeleteTodoProject(project.id)}>
                刪除項目
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default TodoProject;
```

🧩 **補充教學：**

* `currentId` 用來判斷目前是否為編輯狀態。
* `resetData` 傳入 `Form` 組件以顯示預設值。
* 刪除或更新後透過 `refetchTodoProjectList()` 即時更新 UI。

#### 5.2 設置首頁畫面
📄 檔案位置：`src/pages/index.tsx`

此頁面示範登入流程與主畫面展示。
登入成功後會顯示 “Logged in”，並可操作 Todo 專案。
```tsx=
import TodoProject from '@/components/Todo';
import { useAuth } from '@/hooks/useAuth';

export default function Home() {
  const { login, access, logout } = useAuth();

  return (
    <main style={{ padding: '2rem' }}>
      {access ? <p>Logged in</p> : <p>Logged out</p>}
      <button onClick={() => login('admin', 'admin')}>Login</button>
      <button onClick={() => logout()}>Logout</button>
      <TodoProject />
    </main>
  );
}

```

💡 **補充：**

* 測試時可以直接使用 `login('admin', 'admin')` 模擬登入。
* 頁面簡單但具備完整 CRUD 與登入流程範例，非常適合教材示範。


<br/>

## TODO LIST TASK 設計

[範例專案](https://github.com/IffyArt/2025-fullstack-course-forntend/tree/feature/todo-list-task)

### servers
📄 檔案位置：`src/servers/todo-tags.ts`
```tsx=
import { apiClient } from '@/helper/api-client';
import { ApiPaginatedResponse, BaseApiResponse } from '@/models/api';
import { TodoTagType } from '@/models/todo';
import { useMutation, useQuery } from '@tanstack/react-query';

const todoTagsListQueryKey = 'todoTagsList';

export const useTodoTagsList = () => {
  return useQuery({
    queryKey: [todoTagsListQueryKey],
    queryFn: async () => {
      const { data } = await apiClient.get('/v1/todo/tags');
      return data as ApiPaginatedResponse<BaseApiResponse<TodoTagType>>;
    },
  });
};

export const useTodoTagsRetrieve = (tagId: string) => {
  return useQuery({
    queryKey: [todoTagsListQueryKey, tagId],
    queryFn: async () => {
      const { data } = await apiClient.get(`/v1/todo/tags/${tagId}`);
      return data as BaseApiResponse<TodoTagType>;
    },
    enabled: !!tagId,
  });
};

export const useTodoTagsCreate = () => {
  return useMutation({
    mutationFn: async (tag: TodoTagType) => {
      const { data } = await apiClient.post('/v1/todo/tags', tag);
      return data;
    },
  });
};

export const useTodoTagUpdate = () => {
  return useMutation({
    mutationFn: async (tag: TodoTagType & { id: string }) => {
      const { data } = await apiClient.put(`/v1/todo/tags/${tag.id}`, tag);
      return data;
    },
  });
};

export const useTodoTagsDelete = () => {
  return useMutation({
    mutationFn: async (tagId: string) => {
      const { data } = await apiClient.delete(`/v1/todo/tags/${tagId}`);
      return data;
    },
  });
};

```

📄 檔案位置：`src/servers/todo-task.ts`
```tsx=
import { apiClient } from '@/helper/api-client';
import { ApiPaginatedResponse, BaseApiResponse } from '@/models/api';
import { TodoTaskType } from '@/models/todo';
import { useMutation, useQuery } from '@tanstack/react-query';

const todoTaskListQueryKey = 'todoTaskList';

export const useTodoTaskList = () => {
  return useQuery({
    queryKey: [todoTaskListQueryKey],
    queryFn: async () => {
      const { data } = await apiClient.get('/v1/todo/tasks');
      return data as ApiPaginatedResponse<BaseApiResponse<TodoTaskType>>;
    },
  });
};

export const useTodoTaskRetrieve = (taskId: string) => {
  return useQuery({
    queryKey: [todoTaskListQueryKey, taskId],
    queryFn: async () => {
      const { data } = await apiClient.get(`/v1/todo/tasks/${taskId}`);
      return data as BaseApiResponse<TodoTaskType>;
    },
    enabled: !!taskId,
  });
};

export const useTodoTaskCreate = () => {
  return useMutation({
    mutationFn: async (task: TodoTaskType) => {
      const { data } = await apiClient.post('/v1/todo/tasks', task);
      return data;
    },
  });
};

export const useTodoTaskUpdate = () => {
  return useMutation({
    mutationFn: async (task: TodoTaskType & { id: string }) => {
      const { data } = await apiClient.put(`/v1/todo/tasks/${task.id}`, task);
      return data;
    },
  });
};

export const useTodoTaskDelete = () => {
  return useMutation({
    mutationFn: async (taskId: string) => {
      const { data } = await apiClient.delete(`/v1/todo/tasks/${taskId}`);
      return data;
    },
  });
};
```

---

### models

📄 檔案位置：`src/models/form-field.ts`
```tsx=import { UseQueryResult } from '@tanstack/react-query';
import { ApiPaginatedResponse, BaseApiResponse } from './api';

export type FormField = {
  label: string;
  name: string;
  type:
    | 'text'
    | 'number'
    | 'email'
    | 'boolean'
    | 'select'
    | 'date'
    | 'textarea'
    | 'query-select'
    | 'query-checkboxes';
  required?: boolean;
  options?: { label: string; value: string }[];
  query?: () => UseQueryResult<
    ApiPaginatedResponse<BaseApiResponse<object>>,
    Error
  >;
};
```

📄 檔案位置：`src/models/todo.ts`
```tsx=import { UseQueryResult } from '@tanstack/react-query';
export enum Priority {
  LOW = 'low',
  MEDIUM = 'medium',
  HIGH = 'high',
  URGENT = 'urgent',
}

export type TodoProjectType = {
  name: string;
  is_public: boolean;
};

export type TodoTagType = {
  name: string;
};

export type TodoTaskType = {
  title: string;
  description: string;
  priority: Priority;
  is_completed: boolean;
  due_date: Date;
  project_id: number;
  tag_ids: number[];
};
```


### fixtures
📄 檔案位置：`src/fixtures/todo-field-config.ts`
```tsx=
import { FormField } from '@/models/form-field';
import { Priority } from '@/models/todo';
import { useTodoProjectList } from '@/servers/todo-project';
import { useTodoTagsList } from '@/servers/todo-tags';

export const todoProjectFieldConfig: FormField[] = [
  {
    label: '名稱',
    name: 'name',
    type: 'text',
    required: true,
  },
  {
    label: '是否公開',
    name: 'is_public',
    type: 'boolean',
  },
];

export const todoTagFieldConfig: FormField[] = [
  {
    label: '名稱',
    name: 'name',
    type: 'text',
    required: true,
  },
];

export const todoTaskFieldConfig: FormField[] = [
  {
    label: '標題',
    name: 'title',
    type: 'text',
    required: true,
  },
  {
    label: '描述',
    name: 'description',
    type: 'textarea',
    required: true,
  },
  {
    label: '優先級',
    name: 'priority',
    type: 'select',
    required: true,
    options: [
      { label: 'Low', value: Priority.LOW },
      { label: 'Medium', value: Priority.MEDIUM },
      { label: 'High', value: Priority.HIGH },
      { label: 'Urgent', value: Priority.URGENT },
    ],
  },
  {
    label: '是否完成',
    name: 'is_completed',
    type: 'boolean',
  },
  {
    label: '截止日期',
    name: 'due_date',
    type: 'date',
  },
  {
    label: '專案名稱',
    name: 'project_id',
    type: 'query-select',
    required: true,
    query: useTodoProjectList,
  },
  {
    label: '標籤項目',
    name: 'tag_ids',
    type: 'query-checkboxes',
    required: true,
    query: useTodoTagsList,
  },
];
```

### components
📄 檔案位置：`src/components/Fields/index.tsx`
```tsx=
import { FormField } from '@/models/form-field';
import { UseFormReturn } from 'react-hook-form';
import { QueryCheckboxes } from './src/QueryCheckboxes';
import { QuerySelect } from './src/QuerySelect';

export type FieldProps = {
  field: FormField;
  methods: UseFormReturn<Record<string, unknown>>;
};
export const TextField = ({ field, methods }: FieldProps) => {
  const { label, name, type } = field;
  const {
    register,
    formState: { errors },
  } = methods;

  switch (type) {
    case 'text':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='text'
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'number':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='number'
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'email':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='email'
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'boolean':
      return (
        <section>
          <label htmlFor={name}>
            <input
              type='checkbox'
              {...register(name, { required: field.required })}
            />
            {label}
          </label>
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'date':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <input
            type='date'
            {...register(name, { required: field.required })}
          />
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'select':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <select {...register(name, { required: field.required })}>
            <option value=''>請選擇 {label}</option>
            {field.options?.map((option) => (
              <option key={option.value} value={option.value}>
                {option.label}
              </option>
            ))}
          </select>
          {errors[name] && <p>{errors[name]?.message}</p>}
        </section>
      );
    case 'textarea':
      return (
        <section>
          <label htmlFor={name}>{label}</label>
          <textarea
            placeholder={`請輸入 ${label}`}
            {...register(name, { required: field.required })}
          />
        </section>
      );

    case 'query-select':
      return <QuerySelect field={field} methods={methods} />;
    case 'query-checkboxes':
      return <QueryCheckboxes field={field} methods={methods} />;
    default:
      return null;
  }
};
```

📄 檔案位置：`src/components/Fields/src/QuerySelect.tsx`
```tsx=
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FieldProps } from '..';

export const QuerySelect = ({ field, methods }: FieldProps) => {
  const { query } = field;
  const { label, name } = field;
  const {
    register,
    formState: { errors },
  } = methods;

  const queryResult = query?.();
  const data =
    queryResult && 'data' in queryResult
      ? (queryResult.data as any)
      : undefined;

  return (
    <section>
      <label htmlFor={name}>{label}</label>
      <select {...register(name, { required: field.required })}>
        <option value=''>請選擇 {label}</option>
        {data?.results?.map((item: any) => (
          <option key={item.id} value={item.id}>
            {item.name}
          </option>
        ))}
      </select>
      {errors[name] && <p>{errors[name]?.message}</p>}
    </section>
  );
};
```


📄 檔案位置：`src/components/Fields/src/QueryCheckboxes.tsx`
```tsx=
/* eslint-disable @typescript-eslint/no-explicit-any */
import { FieldProps } from '..';

export const QueryCheckboxes = ({ field, methods }: FieldProps) => {
  const { query } = field;
  const { label, name } = field;
  const {
    register,
    formState: { errors },
  } = methods;

  const queryResult = query?.();
  const data =
    queryResult && 'data' in queryResult
      ? (queryResult.data as any)
      : undefined;

  return (
    <section>
      <label htmlFor={name}>{label}</label>
      {data?.results?.map((item: any) => (
        <label key={item.id} htmlFor={`${name}-${item.id}`}>
          <input
            type='checkbox'
            value={item.id}
            id={`${name}-${item.id}`}
            {...register(name, { required: field.required })}
          />
          {item.name}
        </label>
      ))}
      {errors[name] && <p>{errors[name]?.message}</p>}
    </section>
  );
};
```


📄 檔案位置：`src/components/TodoTag/index.tsx`
```tsx=
import { todoTagFieldConfig } from '@/fixtures/todo-field-config';
import { TodoTagType } from '@/models/todo';
import {
  useTodoTagsCreate,
  useTodoTagsDelete,
  useTodoTagsList,
  useTodoTagsRetrieve,
  useTodoTagUpdate,
} from '@/servers/todo-tags';
import { useState } from 'react';
import { Form } from '../Form';

const TodoTag = () => {
  const [currentId, setCurrentId] = useState<number | null>(null);

  const {
    data: todoTagList,
    refetch: refetchTodoTagList,
    isFetching: isFetchingTodoTagList,
    isPending: isPendingTodoTagList,
  } = useTodoTagsList();
  const { data: todoTagData } = useTodoTagsRetrieve(
    currentId?.toString() ?? '',
  );
  const { mutate: createTodoTag } = useTodoTagsCreate();
  const { mutate: updateTodoTag } = useTodoTagUpdate();
  const { mutate: deleteTodoTag } = useTodoTagsDelete();

  const handleCreateTodoTag = (data: TodoTagType) => {
    createTodoTag(data, {
      onSuccess: () => {
        refetchTodoTagList();
      },
    });
  };

  const handleUpdateTodoTag = (data: TodoTagType) => {
    updateTodoTag(
      { ...data, id: currentId?.toString() ?? '' },
      {
        onSuccess: () => {
          refetchTodoTagList();
          setCurrentId(null);
        },
      },
    );
  };

  const handleDeleteTodoTag = (id: number) => {
    deleteTodoTag(id.toString(), {
      onSuccess: () => {
        refetchTodoTagList();
      },
    });
  };

  return (
    <div>
      {isPendingTodoTagList || isFetchingTodoTagList ? (
        <div>Loading...</div>
      ) : (
        <>
          <h1>Todo Tag 新增表單</h1>
          <Form
            fields={todoTagFieldConfig}
            onSubmit={(data) => handleCreateTodoTag(data as TodoTagType)}
          />

          {currentId && (
            <>
              <h1>
                Todo Tag 編輯表單{' '}
                <button onClick={() => setCurrentId(null)}>取消編輯</button>
              </h1>
              <Form
                fields={todoTagFieldConfig}
                onSubmit={(data) => handleUpdateTodoTag(data as TodoTagType)}
                resetData={todoTagData}
              />
            </>
          )}
        </>
      )}

      {todoTagList && (
        <ul>
          {todoTagList?.results?.map((tag) => (
            <li key={tag.id}>
              <div>{tag.name}</div>
              <button onClick={() => setCurrentId(tag.id)}>編輯欄位</button>
              <button onClick={() => handleDeleteTodoTag(tag.id)}>
                刪除項目
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default TodoTag;
```

📄 檔案位置：`src/components/TodoTask/index.tsx`
```tsx=
import { todoTaskFieldConfig } from '@/fixtures/todo-field-config';
import { TodoTaskType } from '@/models/todo';
import {
  useTodoTaskCreate,
  useTodoTaskDelete,
  useTodoTaskList,
  useTodoTaskRetrieve,
  useTodoTaskUpdate,
} from '@/servers/todo-task';
import { useState } from 'react';
import { Form } from '../Form';

const TodoTask = () => {
  const [currentId, setCurrentId] = useState<number | null>(null);

  const {
    data: todoTaskList,
    refetch: refetchTodoTaskList,
    isFetching: isFetchingTodoTaskList,
    isPending: isPendingTodoTaskList,
  } = useTodoTaskList();
  const { data: todoTaskData } = useTodoTaskRetrieve(
    currentId?.toString() ?? '',
  );
  console.log(todoTaskList);
  const { mutate: createTodoTask } = useTodoTaskCreate();
  const { mutate: updateTodoTask } = useTodoTaskUpdate();
  const { mutate: deleteTodoTask } = useTodoTaskDelete();

  const handleCreateTodoTask = (data: TodoTaskType) => {
    createTodoTask(data, {
      onSuccess: () => {
        refetchTodoTaskList();
      },
    });
  };

  const handleUpdateTodoTask = (data: TodoTaskType) => {
    updateTodoTask(
      { ...data, id: currentId?.toString() ?? '' },
      {
        onSuccess: () => {
          refetchTodoTaskList();
          setCurrentId(null);
        },
      },
    );
  };

  const handleDeleteTodoTask = (id: number) => {
    deleteTodoTask(id.toString(), {
      onSuccess: () => {
        refetchTodoTaskList();
      },
    });
  };

  return (
    <div>
      {isPendingTodoTaskList || isFetchingTodoTaskList ? (
        <div>Loading...</div>
      ) : (
        <>
          <h1>Todo Task 新增表單</h1>
          <Form
            fields={todoTaskFieldConfig}
            onSubmit={(data) => handleCreateTodoTask(data as TodoTaskType)}
          />

          {currentId && (
            <>
              <h1>
                Todo Task 編輯表單{' '}
                <button onClick={() => setCurrentId(null)}>取消編輯</button>
              </h1>
              <Form
                fields={todoTaskFieldConfig}
                onSubmit={(data) => handleUpdateTodoTask(data as TodoTaskType)}
                resetData={todoTaskData}
              />
            </>
          )}
        </>
      )}

      {todoTaskList && (
        <ul>
          {todoTaskList?.results?.map((task) => (
            <li key={task.id}>
              <div>
                {task.title}
                <br />
                {task.description}
                <br />
                {task.priority}
                <br />
                {task.project_id}
                <br />
                {task.is_completed ? '完成' : '未完成'}
              </div>
              <button onClick={() => setCurrentId(task.id)}>編輯欄位</button>
              <button onClick={() => handleDeleteTodoTask(task.id)}>
                刪除項目
              </button>
            </li>
          ))}
        </ul>
      )}
    </div>
  );
};

export default TodoTask;
```

<!-- 
### servers
📄 檔案位置：`src/servers/todo-tags.ts`
```tsx=
```

📄 檔案位置：`src/servers/todo-task.ts`
```tsx=
```
 -->
 
### 處理表單設置初始值機制

📄 檔案位置：`src/components/Form/index.tsx`
```tsx=
import { FormField } from '@/models/form-field';
import { useEffect } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import { TextField } from '../Fields';

type Props = {
  fields: FormField[];
  onSubmit: (data: Record<string, unknown>) => void;
  resetData?: Record<string, unknown>;
};

export const Form = ({ fields, onSubmit, resetData }: Props) => {
  const methods = useForm<Record<string, unknown>>();

  useEffect(() => {
    if (resetData) {
      fields.forEach((field) => {
        const fieldValue = resetData[field.name];
        switch (field.type) {
          case 'date':
            methods.setValue(
              field.name,
              new Date(fieldValue as string).toISOString().split('T')[0],
            );
            break;
          case 'query-select':
            const queryKey = field.name.replace('_id', '');
            const targetValue = (
              resetData[queryKey] as { id: number }
            ).id.toString();
            methods.setValue(field.name, targetValue);
            break;
          default:
            methods.setValue(field.name, fieldValue);
            break;
        }
      });
    }
  }, [fields, resetData, methods]);

  const handleSubmit = (data: Record<string, unknown>) => {
    onSubmit(data);
  };

  return (
    <FormProvider {...methods}>
      <form
        onSubmit={methods.handleSubmit(handleSubmit)}
        style={{ border: '1px solid #000', padding: '1rem' }}
      >
        <article>
          {fields.map((field) => (
            <TextField key={field.name} field={field} methods={methods} />
          ))}
        </article>
        <footer>
          <button type='submit'>Submit</button>
        </footer>
      </form>
    </FormProvider>
  );
};
```