SKILL.md
唯讀
名稱
convex-realtime
描述
建立反應式應用程式的模式,包括訂閱管理、樂觀更新、快取行為以及基於游標的分頁查詢
版本
1.0.0
Convex Realtime
使用 Convex 的即時訂閱、樂觀更新、智慧快取和基於游標的分頁來建立反應式應用程式。
文件來源
在實作之前,請不要假設,先取得最新文件:
- 主要:https://docs.convex.dev/client/react
- 樂觀更新:https://docs.convex.dev/client/react/optimistic-updates
- 分頁:https://docs.convex.dev/database/pagination
- 更廣泛的上下文:https://docs.convex.dev/llms.txt
說明
Convex Realtime 的運作方式
- 自動訂閱 - useQuery 會建立一個自動更新的訂閱
- 智慧快取 - 查詢結果會被快取並在元件之間共享
- 一致性 - 所有訂閱都看到一致的資料庫視圖
- 高效更新 - 僅在相關資料變更時重新渲染
基本訂閱
// 具有即時資料的 React 元件
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function TaskList({ userId }: { userId: Id<"users"> }) {
// 自動訂閱並即時更新
const tasks = useQuery(api.tasks.list, { userId });
if (tasks === undefined) {
return <div>載入中...</div>;
}
return (
<ul>
{tasks.map((task) => (
<li key={task._id}>{task.title}</li>
))}
</ul>
);
}
條件式查詢
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function UserProfile({ userId }: { userId: Id<"users"> | null }) {
// 當 userId 為 null 時跳過查詢
const user = useQuery(
api.users.get,
userId ? { userId } : "skip"
);
if (userId === null) {
return <div>選擇一個使用者</div>;
}
if (user === undefined) {
return <div>載入中...</div>;
}
return <div>{user.name}</div>;
}
帶即時更新的變異操作
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function TaskManager({ userId }: { userId: Id<"users"> }) {
const tasks = useQuery(api.tasks.list, { userId });
const createTask = useMutation(api.tasks.create);
const toggleTask = useMutation(api.tasks.toggle);
const handleCreate = async (title: string) => {
// 變異操作會在資料變更時觸發自動重新渲染
await createTask({ title, userId });
};
const handleToggle = async (taskId: Id<"tasks">) => {
await toggleTask({ taskId });
};
return (
<div>
<button onClick={() => handleCreate("新任務")}>新增任務</button>
<ul>
{tasks?.map((task) => (
<li key={task._id} onClick={() => handleToggle(task._id)}>
{task.completed ? "✓" : "○"} {task.title}
</li>
))}
</ul>
</div>
);
}
樂觀更新
在伺服器確認前立即顯示變更:
import { useMutation, useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
import { Id } from "../convex/_generated/dataModel";
function TaskItem({ task }: { task: Task }) {
const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
(localStore, args) => {
const { taskId } = args;
const currentValue = localStore.getQuery(api.tasks.get, { taskId });
if (currentValue !== undefined) {
localStore.setQuery(api.tasks.get, { taskId }, {
...currentValue,
completed: !currentValue.completed,
});
}
}
);
return (
<div onClick={() => toggleTask({ taskId: task._id })}>
{task.completed ? "✓" : "○"} {task.title}
</div>
);
}
列表的樂觀更新
import { useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
function useCreateTask(userId: Id<"users">) {
return useMutation(api.tasks.create).withOptimisticUpdate(
(localStore, args) => {
const { title, userId } = args;
const currentTasks = localStore.getQuery(api.tasks.list, { userId });
if (currentTasks !== undefined) {
// 將樂觀任務加入列表
const optimisticTask = {
_id: crypto.randomUUID() as Id<"tasks">,
_creationTime: Date.now(),
title,
userId,
completed: false,
};
localStore.setQuery(api.tasks.list, { userId }, [
optimisticTask,
...currentTasks,
]);
}
}
);
}
基於游標的分頁
// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";
export const listPaginated = query({
args: {
channelId: v.id("channels"),
paginationOpts: paginationOptsValidator,
},
handler: async (ctx, args) => {
return await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.paginate(args.paginationOpts);
},
});
// 具有分頁功能的 React 元件
import { usePaginatedQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{ channelId },
{ initialNumItems: 20 }
);
return (
<div>
{results.map((message) => (
<div key={message._id}>{message.content}</div>
))}
{status === "CanLoadMore" && (
<button onClick={() => loadMore(20)}>載入更多</button>
)}
{status === "LoadingMore" && <div>載入中...</div>}
{status === "Exhausted" && <div>沒有更多訊息</div>}
</div>
);
}
無限滾動模式
import { usePaginatedQuery } from "convex/react";
import { useEffect, useRef } from "react";
import { api } from "../convex/_generated/api";
function InfiniteMessageList({ channelId }: { channelId: Id<"channels"> }) {
const { results, status, loadMore } = usePaginatedQuery(
api.messages.listPaginated,
{ channelId },
{ initialNumItems: 20 }
);
const observerRef = useRef<IntersectionObserver>();
const loadMoreRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (observerRef.current) {
observerRef.current.disconnect();
}
observerRef.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && status === "CanLoadMore") {
loadMore(20);
}
});
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => observerRef.current?.disconnect();
}, [status, loadMore]);
return (
<div>
{results.map((message) => (
<div key={message._id}>{message.content}</div>
))}
<div ref={loadMoreRef} style={{ height: 1 }} />
{status === "LoadingMore" && <div>載入中...</div>}
</div>
);
}
多個訂閱
import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";
function Dashboard({ userId }: { userId: Id<"users"> }) {
// 多個訂閱獨立更新
const user = useQuery(api.users.get, { userId });
const tasks = useQuery(api.tasks.list, { userId });
const notifications = useQuery(api.notifications.unread, { userId });
const isLoading = user === undefined ||
tasks === undefined ||
notifications === undefined;
if (isLoading) {
return <div>載入中...</div>;
}
return (
<div>
<h1>歡迎, {user.name}</h1>
<p>你有 {tasks.length} 個任務</p>
<p>{notifications.length} 則未讀通知</p>
</div>
);
}
範例
即時聊天應用程式
// convex/messages.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const list = query({
args: { channelId: v.id("channels") },
returns: v.array(v.object({
_id: v.id("messages"),
_creationTime: v.number(),
content: v.string(),
authorId: v.id("users"),
authorName: v.string(),
})),
handler: async (ctx, args) => {
const messages = await ctx.db
.query("messages")
.withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
.order("desc")
.take(100);
// 補充作者名稱
return Promise.all(
messages.map(async (msg) => {
const author = await ctx.db.get(msg.authorId);
return {
...msg,
authorName: author?.name ?? "未知",
};
})
);
},
});
export const send = mutation({
args: {
channelId: v.id("channels"),
authorId: v.id("users"),
content: v.string(),
},
returns: v.id("messages"),
handler: async (ctx, args) => {
return await ctx.db.insert("messages", {
channelId: args.channelId,
authorId: args.authorId,
content: args.content,
});
},
});
// ChatRoom.tsx
import { useQuery, useMutation } from "convex/react";
import { api } from "../convex/_generated/api";
import { useState, useRef, useEffect } from "react";
function ChatRoom({ channelId, userId }: Props) {
const messages = useQuery(api.messages.list, { channelId });
const sendMessage = useMutation(api.messages.send);
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
// 新訊息時自動滾動到底部
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = async (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim()) return;
await sendMessage({
channelId,
authorId: userId,
content: input.trim(),
});
setInput("");
};
return (
<div className="chat-room">
<div className="messages">
{messages?.map((msg) => (
<div key={msg._id} className="message">
<strong>{msg.authorName}:</strong> {msg.content}
</div>
))}
<div ref={messagesEndRef} />
</div>
<form onSubmit={handleSend}>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="輸入訊息..."
/>
<button type="submit">傳送</button>
</form>
</div>
);
}
最佳實務
- 除非明確指示,否則不要執行
npx convex deploy - 除非明確指示,否則不要執行任何 git 指令
- 使用 "skip" 進行條件式查詢,而不是條件式呼叫 hook
- 實作樂觀更新以獲得更好的感知效能
- 對大型資料集使用 usePaginatedQuery
- 明確處理 undefined 狀態(載入中)
- 透過記憶化衍生資料來避免不必要的重新渲染
常見陷阱
- 條件式 hook 呼叫 - 使用 "skip" 而不是 if 陳述式
- 未處理載入狀態 - 永遠檢查 undefined
- 缺少樂觀更新回滾 - 樂觀更新在錯誤時會自動回滾
- 分頁時過度擷取 - 使用適當的頁面大小
- 忽略訂閱清理 - React 會自動處理
參考資料
- Convex 文件:https://docs.convex.dev/
- Convex LLMs.txt:https://docs.convex.dev/llms.txt
- React 客戶端:https://docs.convex.dev/client/react
- 樂觀更新:https://docs.convex.dev/client/react/optimistic-updates
- 分頁:https://docs.convex.dev/database/pagination






