convex-best-practices について
convex-best-practicesは、Convexアプリケーションを開発する人向けのコーディングスキルです。関数設計、検証、クエリ、TypeScript、エラー処理の指針を示します。
主な機能
- 01ドメイン別の関数整理
- 02引数と戻り値の検証
- 03インデックスを使ったクエリ
- 04ConvexErrorによるエラー処理
- 05冪等なミューテーションによる競合軽減
- 06TypeScriptのIdとDoc型の活用
活用シーン
- 01Convex関数APIの設計
- 02クエリ効率の改善
- 03検証とエラー処理の追加
- 04ミューテーション競合の低減
インストール
コマンドをコピーしてターミナルで実行します。
$
npx skills add https:/ / github.com/ julianromli/ ai-skills --skill convex-best-practicesインストール後、Claude Code で/convex-best-practicesを入力して読み込まれたか確認します。
詳細
Convex のベストプラクティス
関数の整理、クエリの最適化、バリデーション、TypeScript の使用、エラー処理に関する確立されたパターンに従って、本番環境に対応した Convex アプリケーションを構築します。
コード品質
このスキルのすべてのパターンは、@convex-dev/eslint-plugin に準拠しています。ビルド時の検証に使用するには、インストールしてください。
npm i @convex-dev/eslint-plugin --save-dev
// eslint.config.js
import { defineConfig } from "eslint/config";
import convexPlugin from "@convex-dev/eslint-plugin";
export default defineConfig([
...convexPlugin.configs.recommended,
]);
このプラグインは、次の 4 つのルールを適用します。
| ルール | 適用内容 |
|---|---|
no-old-registered-function-syntax | handler を使うオブジェクト構文 |
require-argument-validators | すべての関数に args: {} を指定 |
explicit-table-ids | db 操作でテーブル名を指定 |
import-wrong-runtime | Convex ランタイムで Node をインポートしない |
ドキュメント:https://docs.convex.dev/eslint
ドキュメントの参照元
実装する前に、推測せず、最新のドキュメントを取得してください。
- 主な参照先:https://docs.convex.dev/understanding/best-practices/
- エラー処理:https://docs.convex.dev/functions/error-handling
- 書き込みの競合:https://docs.convex.dev/error#1
- より広い背景情報:https://docs.convex.dev/llms.txt
手順
Convex の心得
- 難しい部分は Convex が管理します - キャッシュ、リアルタイム同期、一貫性は Convex に任せます
- 関数が API です - 関数をアプリケーションのインターフェースとして設計します
- スキーマが真実です - schema.ts でデータモデルを明示的に定義します
- どこでも TypeScript を使います - エンドツーエンドの型安全性を活用します
- クエリはリアクティブです - リクエストではなく、サブスクリプションとして考えます
関数の整理
Convex 関数をドメインごとに整理します。
// convex/users.ts - User-related functions
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
export const get = query({
args: { userId: v.id("users") },
returns: v.union(
v.object({
_id: v.id("users"),
_creationTime: v.number(),
name: v.string(),
email: v.string(),
}),
v.null(),
),
handler: async (ctx, args) => {
return await ctx.db.get("users", args.userId);
},
});
引数と戻り値のバリデーション
引数と戻り値の両方に、必ずバリデーターを定義します。
export const createTask = mutation({
args: {
title: v.string(),
description: v.optional(v.string()),
priority: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
},
returns: v.id("tasks"),
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
title: args.title,
description: args.description,
priority: args.priority,
completed: false,
createdAt: Date.now(),
});
},
});
クエリのパターン
効率的なクエリには、フィルターではなくインデックスを使用します。
// Schema with index
export default defineSchema({
tasks: defineTable({
userId: v.id("users"),
status: v.string(),
createdAt: v.number(),
})
.index("by_user", ["userId"])
.index("by_user_and_status", ["userId", "status"]),
});
// Query using index
export const getTasksByUser = query({
args: { userId: v.id("users") },
returns: v.array(
v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
userId: v.id("users"),
status: v.string(),
createdAt: v.number(),
}),
),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.order("desc")
.collect();
},
});
エラー処理
ユーザーに表示するエラーには ConvexError を使用します。
import { ConvexError } from "convex/values";
export const updateTask = mutation({
args: {
taskId: v.id("tasks"),
title: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get("tasks", args.taskId);
if (!task) {
throw new ConvexError({
code: "NOT_FOUND",
message: "Task not found",
});
}
await ctx.db.patch("tasks", args.taskId, { title: args.title });
return null;
},
});
書き込みの競合を避ける(楽観的同時実行制御)
Convex は OCC を使用します。競合を最小限に抑えるには、次のパターンに従ってください。
// GOOD: Make mutations idempotent
export const completeTask = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
const task = await ctx.db.get("tasks", args.taskId);
// Early return if already complete (idempotent)
if (!task || task.status === "completed") {
return null;
}
await ctx.db.patch("tasks", args.taskId, {
status: "completed",
completedAt: Date.now(),
});
return null;
},
});
// GOOD: Patch directly without reading first when possible
export const updateNote = mutation({
args: { id: v.id("notes"), content: v.string() },
returns: v.null(),
handler: async (ctx, args) => {
// Patch directly - ctx.db.patch throws if document doesn't exist
await ctx.db.patch("notes", args.id, { content: args.content });
return null;
},
});
// GOOD: Use Promise.all for parallel independent updates
export const reorderItems = mutation({
args: { itemIds: v.array(v.id("items")) },
returns: v.null(),
handler: async (ctx, args) => {
const updates = args.itemIds.map((id, index) =>
ctx.db.patch("items", id, { order: index }),
);
await Promise.all(updates);
return null;
},
});
TypeScript のベストプラクティス
import { Id, Doc } from "./_generated/dataModel";
// Use Id type for document references
type UserId = Id<"users">;
// Use Doc type for full documents
type User = Doc<"users">;
// Define Record types properly
const userScores: Record<Id<"users">, number> = {};
内部関数と公開関数
// Public function - exposed to clients
export const getUser = query({
args: { userId: v.id("users") },
returns: v.union(
v.null(),
v.object({
/* ... */
}),
),
handler: async (ctx, args) => {
// ...
},
});
// Internal function - only callable from other Convex functions
export const _updateUserStats = internalMutation({
args: { userId: v.id("users") },
returns: v.null(),
handler: async (ctx, args) => {
// ...
},
});
例
CRUD パターンの全体例
// convex/tasks.ts
import { query, mutation } from "./_generated/server";
import { v } from "convex/values";
import { ConvexError } from "convex/values";
const taskValidator = v.object({
_id: v.id("tasks"),
_creationTime: v.number(),
title: v.string(),
completed: v.boolean(),
userId: v.id("users"),
});
export const list = query({
args: { userId: v.id("users") },
returns: v.array(taskValidator),
handler: async (ctx, args) => {
return await ctx.db
.query("tasks")
.withIndex("by_user", (q) => q.eq("userId", args.userId))
.collect();
},
});
export const create = mutation({
args: {
title: v.string(),
userId: v.id("users"),
},
returns: v.id("tasks"),
handler: async (ctx, args) => {
return await ctx.db.insert("tasks", {
title: args.title,
completed: false,
userId: args.userId,
});
},
});
export const update = mutation({
args: {
taskId: v.id("tasks"),
title: v.optional(v.string()),
completed: v.optional(v.boolean()),
},
returns: v.null(),
handler: async (ctx, args) => {
const { taskId, ...updates } = args;
// Remove undefined values
const cleanUpdates = Object.fromEntries(
Object.entries(updates).filter(([_, v]) => v !== undefined),
);
if (Object.keys(cleanUpdates).length > 0) {
await ctx.db.patch("tasks", taskId, cleanUpdates);
}
return null;
},
});
export const remove = mutation({
args: { taskId: v.id("tasks") },
returns: v.null(),
handler: async (ctx, args) => {
await ctx.db.delete("tasks", args.taskId);
return null;
},
});
ベストプラクティス
- 明示的に指示されない限り、
npx convex deployを実行しないでください - 明示的に指示されない限り、git コマンドを実行しないでください
- 関数には必ず戻り値のバリデーターを定義してください
- データをフィルタリングするすべてのクエリでインデックスを使用してください
- 再試行に適切に対応できるよう、ミューテーションを冪等にしてください
- ユーザーに表示するエラーメッセージには ConvexError を使用してください
- 関数をドメインごとに整理してください(users.ts、tasks.ts など)
- 機密性の高い操作には内部関数を使用してください
- TypeScript の Id 型と Doc 型を活用してください
よくある落とし穴
- withIndex ではなく filter を使用する - 必ずインデックスを定義し、withIndex を使用してください
- 戻り値のバリデーターがない - 必ず returns フィールドを指定してください
- 冪等でないミューテーション - 更新前に現在の状態を確認してください
- 不要な読み取りの後にパッチを適用する - 可能な場合は直接パッチを適用してください
- null の戻り値を処理しない - ドキュメントの ID が存在しない場合があります
参考資料
- Convex ドキュメント:https://docs.convex.dev/
- Convex LLMs.txt:https://docs.convex.dev/llms.txt
- ベストプラクティス:https://docs.convex.dev/understanding/best-practices/
- エラー処理:https://docs.convex.dev/functions/error-handling
- 書き込みの競合:https://docs.convex.dev/error#1
コメント
Markdown 対応