跳转到内容

React 集成

PocketBase JS SDK 可以轻松集成到 React 项目中。本文介绍 React Hooks、Context 封装以及常见使用场景。

Terminal window
npm install pocketbase
# 或
yarn add pocketbase
# 或
pnpm add pocketbase

创建 src/lib/pocketbase.ts

src/lib/pocketbase.ts
import PocketBase from "pocketbase";
const pb = new PocketBase(
import.meta.env.VITE_POCKETBASE_URL || "http://127.0.0.1:8090",
);
export default pb;
src/types/pocketbase.d.ts
import PocketBase from "pocketbase";
// 扩展 PocketBase 类型
declare module "pocketbase" {
interface CollectionRecords {
// 用户集合
users: {
id: string;
collectionId: string;
collectionName: string;
username?: string;
email: string;
emailVisibility: boolean;
verified: boolean;
name?: string;
role?: "user" | "admin";
avatar?: string;
created: string;
updated: string;
};
// 文章集合
posts: {
id: string;
collectionId: string;
collectionName: string;
title: string;
slug: string;
content: string;
excerpt?: string;
cover?: string;
status: "draft" | "published" | "archived";
author: string;
category?: string;
tags?: string[];
featured: boolean;
views: number;
publishedAt?: string;
created: string;
updated: string;
};
// 评论集合
comments: {
id: string;
content: string;
post: string;
author: string;
parent?: string;
status: "pending" | "approved" | "rejected";
created: string;
updated: string;
};
}
}
src/contexts/AuthContext.tsx
import { createContext, useContext, useEffect, useState, ReactNode } from 'react';
import pb from '@/lib/pocketbase';
interface AuthContextType {
user: any;
token: string;
isAuthenticated: boolean;
login: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, passwordConfirm: string) => Promise<void>;
logout: () => void;
refresh: () => Promise<void>;
loading: boolean;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState(pb.authStore.model);
const [token, setToken] = useState(pb.authStore.token);
const [loading, setLoading] = useState(false);
useEffect(() => {
// 监听认证状态变化
const unsubscribe = pb.authStore.onChange((token, model) => {
setToken(token);
setUser(model);
});
return unsubscribe;
}, []);
const login = async (email: string, password: string) => {
setLoading(true);
try {
const authData = await pb.collection('users').authWithPassword(email, password);
return authData;
} finally {
setLoading(false);
}
};
const register = async (email: string, password: string, passwordConfirm: string) => {
setLoading(true);
try {
const record = await pb.collection('users').create({
email,
password,
passwordConfirm
});
return record;
} finally {
setLoading(false);
}
};
const logout = () => {
pb.authStore.clear();
setUser(null);
setToken('');
};
const refresh = async () => {
try {
await pb.collection('users').authRefresh();
} catch (err) {
logout();
throw err;
}
};
return (
<AuthContext.Provider
value={{
user,
token,
isAuthenticated: !!token,
login,
register,
logout,
refresh,
loading
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
src/hooks/useCollection.ts
import { useState, useEffect, useCallback } from "react";
import pb from "@/lib/pocketbase";
interface UseCollectionOptions {
expand?: string;
autoFetch?: boolean;
}
export function useCollection<T>(
collectionName: string,
options: UseCollectionOptions = {},
) {
const { expand = "", autoFetch = true } = options;
const [items, setItems] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [totalItems, setTotalItems] = useState(0);
const getList = useCallback(
async (page = 1, perPage = 20, filter = "", sort = "") => {
setLoading(true);
setError(null);
try {
const result = await pb
.collection(collectionName)
.getList(page, perPage, {
filter,
sort,
expand,
});
setItems(result.items);
setTotalItems(result.totalItems);
return result;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
},
[collectionName, expand],
);
const getOne = useCallback(
async (id: string) => {
setLoading(true);
setError(null);
try {
const record = await pb
.collection(collectionName)
.getOne(id, { expand });
return record;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
},
[collectionName, expand],
);
const create = useCallback(
async (data: any) => {
setLoading(true);
setError(null);
try {
const record = await pb.collection(collectionName).create(data);
setItems((prev) => [record, ...prev]);
return record;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
},
[collectionName],
);
const update = useCallback(
async (id: string, data: any) => {
setLoading(true);
setError(null);
try {
const record = await pb.collection(collectionName).update(id, data);
setItems((prev) =>
prev.map((item) => (item.id === id ? record : item)),
);
return record;
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
},
[collectionName],
);
const remove = useCallback(
async (id: string) => {
setLoading(true);
setError(null);
try {
await pb.collection(collectionName).delete(id);
setItems((prev) => prev.filter((item) => item.id !== id));
} catch (err) {
setError(err as Error);
throw err;
} finally {
setLoading(false);
}
},
[collectionName],
);
useEffect(() => {
if (autoFetch) {
getList();
}
}, [autoFetch, getList]);
return {
items,
loading,
error,
totalItems,
getList,
getOne,
create,
update,
remove,
};
}
src/hooks/useRealtime.ts
import { useEffect, useRef } from "react";
import pb from "@/lib/pocketbase";
export function useRealtime(
collection: string,
callback: (event: any) => void,
filter = "*",
) {
const subscriptionRef = useRef<any>(null);
useEffect(() => {
let mounted = true;
const subscribe = async () => {
try {
const sub = await pb.collection(collection).subscribe(filter, (e) => {
if (mounted) {
callback(e);
}
});
if (mounted) {
subscriptionRef.current = sub;
}
} catch (err) {
console.error("Subscription failed:", err);
}
};
subscribe();
return () => {
mounted = false;
subscriptionRef.current?.unsubscribe();
};
}, [collection, filter, callback]);
return subscriptionRef.current;
}
src/hooks/usePagination.ts
import { useState, useCallback } from "react";
export function usePagination(initialPage = 1, initialPerPage = 20) {
const [page, setPage] = useState(initialPage);
const [perPage, setPerPage] = useState(initialPerPage);
const nextPage = useCallback(() => {
setPage((prev) => prev + 1);
}, []);
const prevPage = useCallback(() => {
setPage((prev) => Math.max(1, prev - 1));
}, []);
const goToPage = useCallback((pageNumber: number) => {
setPage(Math.max(1, pageNumber));
}, []);
const reset = useCallback(() => {
setPage(initialPage);
}, [initialPage]);
return {
page,
perPage,
setPage,
setPerPage,
nextPage,
prevPage,
goToPage,
reset,
};
}
src/components/LoginForm.tsx
import { useState } from "react";
import { useAuth } from "@/contexts/AuthContext";
import { useNavigate } from "react-router-dom";
export function LoginForm() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const { login, loading } = useAuth();
const navigate = useNavigate();
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
try {
await login(email, password);
navigate("/dashboard");
} catch (err: any) {
setError(err.message || "Login failed");
}
}
return (
<form onSubmit={handleSubmit} className="login-form">
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
{error && <div className="error">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? "Logging in..." : "Login"}
</button>
</form>
);
}
src/components/PostList.tsx
import { useEffect } from "react";
import { useCollection } from "@/hooks/useCollection";
import { usePagination } from "@/hooks/usePagination";
import { useRealtime } from "@/hooks/useRealtime";
export function PostList() {
const { page, perPage, nextPage, prevPage } = usePagination(1, 10);
const { items, loading, error, totalItems, getList } = useCollection(
"posts",
{
expand: "author,category",
autoFetch: false,
},
);
// 实时订阅
useRealtime("posts", (e) => {
console.log("Post changed:", e.action, e.record);
});
useEffect(() => {
getList(page, perPage, "status='published'", "-created");
}, [page, perPage, getList]);
if (loading && items.length === 0) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error.message}</div>;
}
return (
<div>
<h1>Posts ({totalItems})</h1>
<div className="posts">
{items.map((post) => (
<article key={post.id} className="post">
<h2>{post.title}</h2>
{post.expand?.author && (
<p className="author">By {post.expand.author.name}</p>
)}
<p>{post.excerpt}</p>
</article>
))}
</div>
<div className="pagination">
<button onClick={prevPage} disabled={page === 1}>
Previous
</button>
<span>Page {page}</span>
<button onClick={nextPage} disabled={items.length < perPage}>
Next
</button>
</div>
</div>
);
}
src/components/FileUpload.tsx
import { useState, useRef } from "react";
import pb from "@/lib/pocketbase";
export function FileUpload() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [preview, setPreview] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
function handleFileSelect(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
if (file) {
setPreview(URL.createObjectURL(file));
}
}
async function handleUpload() {
const file = fileInput.current?.files?.[0];
if (!file) return;
setUploading(true);
setProgress(0);
return new Promise((resolve, reject) => {
const formData = new FormData();
formData.append("file", file);
formData.append("title", "Uploaded file");
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener("progress", (e) => {
if (e.lengthComputable) {
setProgress(Math.round((e.loaded / e.total) * 100));
}
});
xhr.addEventListener("load", () => {
if (xhr.status === 200) {
const response = JSON.parse(xhr.responseText);
resolve(response);
} else {
reject(new Error(xhr.statusText));
}
setUploading(false);
});
xhr.addEventListener("error", () => {
reject(new Error("Upload failed"));
setUploading(false);
});
xhr.open("POST", `${pb.baseUrl}/api/collections/uploads/records`);
xhr.setRequestHeader("Authorization", `Bearer ${pb.authStore.token}`);
xhr.send(formData);
});
}
return (
<div className="file-upload">
<input
ref={fileInput}
type="file"
onChange={handleFileSelect}
disabled={uploading}
/>
{preview && <img src={preview} alt="Preview" style={{ maxWidth: 200 }} />}
<button onClick={handleUpload} disabled={uploading || !preview}>
{uploading ? `Uploading ${progress}%` : "Upload"}
</button>
</div>
);
}
src/components/ProtectedRoute.tsx
import { Navigate } from "react-router-dom";
import { useAuth } from "@/contexts/AuthContext";
interface ProtectedRouteProps {
children: React.ReactNode;
requireAdmin?: boolean;
}
export function ProtectedRoute({
children,
requireAdmin = false,
}: ProtectedRouteProps) {
const { isAuthenticated, user } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
if (requireAdmin && user?.role !== "admin") {
return <Navigate to="/" replace />;
}
return <>{children}</>;
}
src/App.tsx
import { BrowserRouter, Routes, Route } from "react-router-dom";
import { AuthProvider } from "./contexts/AuthContext";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { LoginForm } from "./components/LoginForm";
import { Dashboard } from "./components/Dashboard";
function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginForm />} />
<Route
path="/dashboard"
element={
<ProtectedRoute>
<Dashboard />
</ProtectedRoute>
}
/>
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
export default App;
  1. 使用 TypeScript:充分利用类型安全,定义好集合类型
  2. 自定义 Hooks:将通用逻辑封装成 hooks 复用
  3. 错误边界:使用 Error Boundary 捕获组件树中的错误
  4. 请求取消:组件卸载时取消未完成的请求
  5. 乐观更新:先更新 UI,失败时回滚
  6. 防抖节流:搜索、滚动等场景使用防抖节流
// 请求取消示例
useEffect(() => {
const abortController = new AbortController();
const fetchData = async () => {
try {
const result = await pb.collection("posts").getList(1, 20, {
// 传递 signal 以支持取消
});
} catch (err) {
if (err.name !== "AbortError") {
setError(err);
}
}
};
fetchData();
return () => {
abortController.abort();
};
}, []);