昨天我們把資料庫用 Prisma 型別安全化了,
今天拉回前端,用 React + TypeScript 把元件到資料流都型別化。
你會拿到:Props/State 模板、事件型別、useRef、自訂 Hook、Context、API 型別串接 以及幾個常見坑的解法。
(如果你已經有 React 專案就跳過)
# 使用 Vite
npm create vite@latest react-ts-demo -- --template react-ts
cd react-ts-demo
npm install
npm run dev
專案內建 tsconfig.json,React 型別也都準備好。
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>
  );
}
interfaceinterface ButtonProps {
  onClick: () => void;
  children: React.ReactNode;
}
export const Button = ({ onClick, children }: ButtonProps) => (
  <button onClick={onClick}>{children}</button>
);
心法:Props 用 type 或 interface 都行;如果需要 Union / 工具型別,type 更順手。
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 混亂)。
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 看提示,多半就能知道正確型別。
useRef:DOM or 變數暫存// DOM 節點
const inputRef = useRef<HTMLInputElement>(null);
<input ref={inputRef} />
// 變數暫存(不觸發重渲染)
const timerRef = useRef<number | null>(null);
坑點:useRef(null) 會變 MutableRefObject<null>;要在角括號把實際型別寫清楚。
我們把 Day 17 驗證 + Day 18 Prisma 的 API 接進來。
(假設有 /users GET、POST)
// 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 精準。// 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>
  );
}
// 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 先給 undefined,useAuth 內檢查、拋錯 → 使用起來保證拿到完整型別。
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>
  );
}
好處:你不用自己重抄 onClick、disabled 等內建屬性型別。
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)把分支切清楚,分支內會自動窄化型別。
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[]> 直接把回傳資料型別帶進來,補全 & 檢查都就位。
React.FC 要不要用?
children 行為;建議在 Props 自己寫 children?: React.ReactNode,更清楚。useRef 初始 null
const ref = useRef<HTMLDivElement>(null); → 之後使用前做 if (ref.current) 判斷。onXxx 上 Ctrl/Cmd + hover 看型別。any
as any;把 fetch().then(res => res.json() as Promise<T>) 或封裝成自訂 Hook,回傳 T。FormData 時手動轉型,或採 zod 驗證後再餵入狀態。