昨天我們把資料庫用 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>
);
}
interface
interface 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
驗證後再餵入狀態。