iT邦幫忙

2025 iThome 鐵人賽

DAY 19
0
Modern Web

我與型別的 30 天約定:TypeScript 入坑實錄系列 第 19

Day 19|React + TypeScript:Props / State / 事件 / Hook / API 串接一次搞懂

  • 分享至 

  • xImage
  •  

昨天我們把資料庫用 Prisma 型別安全化了,

今天拉回前端,用 React + TypeScript 把元件到資料流都型別化。

你會拿到:Props/State 模板、事件型別、useRef、自訂 Hook、Context、API 型別串接 以及幾個常見坑的解法。


0. 專案快速初始化(可直接套)

(如果你已經有 React 專案就跳過)

# 使用 Vite
npm create vite@latest react-ts-demo -- --template react-ts
cd react-ts-demo
npm install
npm run dev

專案內建 tsconfig.json,React 型別也都準備好。


1) Props 型別:兩種常用寫法

寫法 A:type(建議)

type UserCardProps = {
  id: string;
  name: string;
  email?: string; // 可選
};

export function UserCard({ id, name, email }: UserCardProps) {
  return (
    <div>
      <h3>{name}</h3>
      {email && <p>{email}</p>}
    </div>
  );
}

寫法 B:interface

interface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
}

export const Button = ({ onClick, children }: ButtonProps) => (
  <button onClick={onClick}>{children}</button>
);

心法:Props 用 type 或 interface 都行;如果需要 Union / 工具型別,type 更順手。


2) State 型別:能推就推,必要時註記

const [count, setCount] = useState(0); // 推論 number

// 複雜物件:明確註記更穩
type User = { id: string; name: string; email?: string };
const [user, setUser] = useState<User | null>(null);

// 延遲初始化
const [list, setList] = useState<string[]>(() => []);

小坑useState<null | T>(null)useState<T>() 好讀(避免初值 undefined/null 混亂)。


3) 事件型別:別再 any

// 按鈕點擊
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {};

// 表單提交
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
  e.preventDefault();
};

// 輸入框變更
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  setValue(e.target.value);
};

技巧:用編輯器把滑鼠移到 onChange 看提示,多半就能知道正確型別。


4) useRef:DOM or 變數暫存

// DOM 節點
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />

// 變數暫存(不觸發重渲染)
const timerRef = useRef<number | null>(null);

坑點useRef(null) 會變 MutableRefObject<null>;要在角括號把實際型別寫清楚。


5) 自訂 Hook:把請求/邏輯抽出來(含型別)

我們把 Day 17 驗證 + Day 18 Prisma 的 API 接進來。

(假設有 /users GETPOST

// src/types.ts
export type User = { id: string; name: string; email: string };

// src/hooks/useUsers.ts
import { useEffect, useState } from "react";
import type { User } from "../types";

type Status = "idle" | "loading" | "success" | "error";

export function useUsers() {
  const [data, setData] = useState<User[]>([]);
  const [status, setStatus] = useState<Status>("idle");
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    let ignore = false;
    setStatus("loading");
    fetch("/users") // 你的後端 Day 15 開在 3000,前端需設 proxy 或改完整 URL
      .then((r) => r.json() as Promise<User[]>)
      .then((res) => {
        if (!ignore) {
          setData(res);
          setStatus("success");
        }
      })
      .catch((e) => {
        if (!ignore) {
          setError(String(e));
          setStatus("error");
        }
      });
    return () => { ignore = true; };
  }, []);

  async function addUser(payload: Pick<User, "id" | "name" | "email">) {
    const r = await fetch("/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    });
    if (!r.ok) throw new Error("Create failed");
    const created = (await r.json()) as User;
    setData((prev) => [created, ...prev]);
  }

  return { data, status, error, addUser };
}

關鍵

  • User 型別從後端共享(或前端自行定義,但要跟後端同步)。
  • Pick<User, ...> 讓 API 請求 payload 精準。

6) 組件實戰:列表 + 新增表單(型別齊全)

// src/components/UserList.tsx
import { useUsers } from "../hooks/useUsers";

export function UserList() {
  const { data, status, error, addUser } = useUsers();

  async function onCreate(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const fd = new FormData(e.currentTarget);
    const id = String(fd.get("id"));
    const name = String(fd.get("name"));
    const email = String(fd.get("email"));
    await addUser({ id, name, email });
    e.currentTarget.reset();
  }

  if (status === "loading") return <p>Loading...</p>;
  if (status === "error") return <p>Error: {error}</p>;

  return (
    <div>
      <form onSubmit={onCreate}>
        <input name="id" placeholder="id" required />
        <input name="name" placeholder="name" required />
        <input name="email" placeholder="email" type="email" required />
        <button type="submit">Create</button>
      </form>

      <ul>
        {data.map((u) => (
          <li key={u.id}>
            <b>{u.name}</b> — {u.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

7) Context 型別化:全域狀態共享模板

// src/contexts/AuthContext.tsx
import { createContext, useContext, useState } from "react";

type AuthUser = { id: string; name: string } | null;

type AuthContextValue = {
  user: AuthUser;
  login: (user: NonNullable<AuthUser>) => void;
  logout: () => void;
};

const AuthContext = createContext<AuthContextValue | undefined>(undefined);

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<AuthUser>(null);
  const value: AuthContextValue = {
    user,
    login: (u) => setUser(u),
    logout: () => setUser(null),
  };
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used within AuthProvider");
  return ctx;
}

重點createContext 先給 undefineduseAuth 內檢查、拋錯 → 使用起來保證拿到完整型別。


8) 可組合的 Props:ComponentProps 與原生屬性繼承

做一個基於 <button> 的通用 Button,支援原生屬性 + 自家 props。

type BaseButtonProps = React.ComponentProps<"button"> & {
  loading?: boolean;
};

export function LoadingButton({ loading, children, ...rest }: BaseButtonProps) {
  return (
    <button disabled={loading || rest.disabled} {...rest}>
      {loading ? "..." : children}
    </button>
  );
}

好處:你不用自己重抄 onClickdisabled 等內建屬性型別。


9) 進階:可辨識聯合(Discriminated Union)做多態元件

type TextProps = { kind: "text"; content: string };
type LinkProps = { kind: "link"; href: string; content: string };

type SmartTextProps = TextProps | LinkProps;

export function SmartText(props: SmartTextProps) {
  if (props.kind === "text") {
    return <span>{props.content}</span>;
  }
  return (
    <a href={props.href} target="_blank">
      {props.content}
    </a>
  );
}

重點:用 kind(literal)把分支切清楚,分支內會自動窄化型別。


10) 第三方資料拉取:React Query 型別加分(可選)

如果你用 React Query:

npm install @tanstack/react-query
import { useQuery, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { User } from "../types";

const qc = new QueryClient();

function useUsersQuery() {
  return useQuery<User[]>({
    queryKey: ["users"],
    queryFn: () => fetch("/users").then((r) => r.json()),
  });
}

export function App() {
  const { data, isLoading } = useUsersQuery();
  return (
    <QueryClientProvider client={qc}>
      {isLoading ? "Loading" : JSON.stringify(data)}
    </QueryClientProvider>
  );
}

重點useQuery<User[]> 直接把回傳資料型別帶進來,補全 & 檢查都就位。


常見坑 & 解法速查

  1. React.FC 要不要用?
    • 可用,但不要依賴其內建 children 行為;建議在 Props 自己寫 children?: React.ReactNode,更清楚。
  2. useRef 初始 null
    • const ref = useRef<HTMLDivElement>(null); → 之後使用前做 if (ref.current) 判斷。
  3. 事件型別太長記不住
    • 用 IDE 提示:在 JSX 的 onXxxCtrl/Cmd + hover 看型別。
  4. 從 API 來的資料為 any
    • 避免 as any;把 fetch().then(res => res.json() as Promise<T>) 或封裝成自訂 Hook,回傳 T
  5. 表單資料型別
    • FormData 時手動轉型,或採 zod 驗證後再餵入狀態。

上一篇
Day 18|Prisma + TypeScript:資料庫型別安全實戰
系列文
我與型別的 30 天約定:TypeScript 入坑實錄19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言