T-CREATOR

Convex クエリ/ミューテーション/アクション チートシート【保存版シンタックス】

Convex クエリ/ミューテーション/アクション チートシート【保存版シンタックス】

Convex を使った開発では、クエリ、ミューテーション、アクションという 3 つの重要な概念があります。これらの使い分けに迷ったことはありませんか。本記事では、それぞれの役割と具体的なシンタックスをチートシート形式で整理し、すぐに実践で使えるコード例を豊富に紹介します。プロジェクトで迷わず開発を進められるよう、各パターンを網羅的にまとめました。

クイックリファレンス(早見表)

関数タイプ比較表

#関数タイプ用途DB 読み取りDB 書き込み外部通信リアクティブReact フック
1queryデータ取得useQuery
2mutationデータ更新useMutation
3action外部連携・複雑な処理useAction

基本構文早見表

#関数タイプインポート基本構文
1queryimport { query } from ".​/​_generated​/​server";export const myQuery = query({ args: { id: v.id("table") }, handler: async (ctx, args) => { ... } });
2mutationimport { mutation } from ".​/​_generated​/​server";export const myMutation = mutation({ args: { data: v.string() }, handler: async (ctx, args) => { ... } });
3actionimport { action } from ".​/​_generated​/​server";import { api }export const myAction = action({ args: { param: v.string() }, handler: async (ctx, args) => { ... } });

よく使う操作早見表

#操作querymutationaction
1全件取得await ctx.db.query("table").collect()await ctx.db.query("table").collect()await ctx.runQuery(api.table.getAll)
2ID 指定取得await ctx.db.get(id)await ctx.db.get(id)await ctx.runQuery(api.table.getById, {})
3条件検索await ctx.db.query("table").filter(...)await ctx.db.query("table").filter(...)await ctx.runQuery(api.table.search, {})
4新規作成☓ 不可await ctx.db.insert("table", { ... })await ctx.runMutation(api.table.create)
5更新☓ 不可await ctx.db.patch(id, { ... })await ctx.runMutation(api.table.update)
6削除☓ 不可await ctx.db.delete(id)await ctx.runMutation(api.table.delete)
7外部 API 呼び出し☓ 不可☓ 不可await fetch("https:​/​​/​...")
8mutation 呼び出し☓ 不可☓ 不可await ctx.runMutation(api.table.fn)
9query 呼び出し☓ 不可☓ 不可await ctx.runQuery(api.table.fn)

バリデーション型早見表

#構文説明
1文字列v.string()文字列型
2数値v.number()数値型
3真偽値v.boolean()真偽値型
4IDv.id("tableName")テーブルの ID 型
5オプショナルv.optional(v.string())省略可能な型
6配列v.array(v.string())配列型
7オブジェクトv.object({ key: v.string() })オブジェクト型
8Unionv.union(v.string(), v.number())複数型のいずれか
9Nullv.null()null 型

背景

Convex とは

Convex は、リアルタイムなデータベースと API を一体化したバックエンドプラットフォームです。従来の REST API や GraphQL とは異なり、リアクティブなデータフローを実現し、フロントエンドの状態が常にバックエンドと同期されます。

3 つの関数タイプ

Convex では、バックエンドのロジックを以下の 3 種類の関数で表現します。

mermaidflowchart TB
  client["クライアント<br/>(React等)"]
  query["query<br/>(データ読み取り)"]
  mutation["mutation<br/>(データ更新)"]
  action["action<br/>(外部処理)"]
  db[("Convex DB")]
  external["外部API<br/>(Stripe, OpenAI等)"]

  client -->|useQuery| query
  client -->|useMutation| mutation
  client -->|useAction| action

  query -->|リアクティブ読み取り| db
  mutation -->|書き込み| db
  action -.->|外部通信| external
  action -.->|内部呼び出し| mutation

この図が示すように、query はデータの読み取り専用、mutation はデータベースへの書き込み、action は外部 API との連携や非リアクティブな処理を担当します。

課題

開発時に直面する疑問

Convex を使い始めると、次のような疑問に直面することが多いです。

  • いつ query を使い、いつ action を使うべきか
  • mutation と action の違いは何か
  • リアクティブ性とは具体的に何を意味するのか
  • 引数のバリデーションはどう書くのか

これらの疑問を解決するには、各関数タイプの特性と使い分けを体系的に理解する必要があります。

混乱しやすいポイント

#ポイントよくある誤解
1query での書き込みquery 内で db.insert しようとしてエラーになる
2mutation の外部通信mutation から fetch を呼ぼうとして失敗する
3action のリアクティブ性action の戻り値がリアクティブに更新されると期待する
4引数の型安全性v.object の定義を省略してランタイムエラーになる

これらの混乱を避けるため、本記事では各関数タイプの正しい使い方を明確に示します。

解決策

3 つの関数タイプの使い分け

以下の表で、それぞれの関数タイプがどのような場面で使われるかを整理しました。

#関数タイプ用途DB 読み取りDB 書き込み外部通信リアクティブ
1queryデータ取得
2mutationデータ更新
3action外部連携・複雑な処理

この表から、query は読み取り専用でリアクティブ、mutation は書き込みが可能でリアクティブ、action は外部通信が可能だがリアクティブではないことがわかります。

基本原則

各関数タイプを選ぶ際の基本原則は以下の通りです。

  • query: データを読み取るだけで、変更はしない。リアルタイムに更新を受け取りたい場合に使用
  • mutation: データベースに変更を加える。フォーム送信やボタンクリックなどのユーザーアクションに対応
  • action: 外部 API との通信や、複雑な非リアクティブな処理が必要な場合に使用

これらの原則を守ることで、Convex の強みであるリアクティブ性を最大限に活用できます。

具体例

クエリ (query) の基本形

インポート

クエリを定義するには、まず必要なモジュールをインポートします。

typescriptimport { query } from './_generated/server';
import { v } from 'convex/values';

引数なしのシンプルなクエリ

最もシンプルなクエリは、引数を取らずにデータを返すものです。

typescriptexport const listTasks = query({
  handler: async (ctx) => {
    // すべてのタスクを取得
    return await ctx.db.query('tasks').collect();
  },
});

このクエリは tasks テーブルのすべてのドキュメントを取得し、配列として返します。

引数付きクエリ

引数を受け取るクエリでは、args プロパティを使ってバリデーションを定義します。

typescriptexport const getTaskById = query({
  args: { taskId: v.id('tasks') },
  handler: async (ctx, args) => {
    // ID でタスクを検索
    const task = await ctx.db.get(args.taskId);
    return task;
  },
});

v.id("tasks") は、Convex の ID 型として taskId をバリデーションします。型安全性が保証されるため、実行時エラーを防げます。

フィルタリングを含むクエリ

インデックスを使ったフィルタリングは、クエリのパフォーマンスを大幅に向上させます。

typescriptexport const getTasksByStatus = query({
  args: { status: v.string() },
  handler: async (ctx, args) => {
    // status インデックスを使ってフィルタリング
    return await ctx.db
      .query('tasks')
      .withIndex('by_status', (q) =>
        q.eq('status', args.status)
      )
      .collect();
  },
});

この例では、by_status というインデックスを使って、特定のステータスのタスクだけを効率的に取得しています。

複数条件のクエリ

複数のフィルタ条件を組み合わせる場合は、filter メソッドを使用します。

typescriptexport const getTasksByUserAndStatus = query({
  args: {
    userId: v.id('users'),
    status: v.string(),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('tasks')
      .withIndex('by_user', (q) =>
        q.eq('userId', args.userId)
      )
      .filter((q) => q.eq(q.field('status'), args.status))
      .collect();
  },
});

withIndex でまず userId でフィルタリングし、その後 filterstatus による絞り込みを行います。

ミューテーション (mutation) の基本形

インポート

ミューテーションも同様に、必要なモジュールをインポートします。

typescriptimport { mutation } from './_generated/server';
import { v } from 'convex/values';

新規作成 (Insert)

データベースに新しいドキュメントを挿入する基本的なミューテーションです。

typescriptexport const createTask = mutation({
  args: {
    title: v.string(),
    description: v.optional(v.string()),
    status: v.string(),
  },
  handler: async (ctx, args) => {
    // 新しいタスクを作成
    const taskId = await ctx.db.insert('tasks', {
      title: args.title,
      description: args.description,
      status: args.status,
      createdAt: Date.now(),
    });
    return taskId;
  },
});

ctx.db.insert は新しいドキュメントを挿入し、生成された ID を返します。v.optional を使うことで、省略可能なフィールドを定義できます。

更新 (Update)

既存のドキュメントを更新するには、patch メソッドを使用します。

typescriptexport const updateTaskStatus = mutation({
  args: {
    taskId: v.id('tasks'),
    status: v.string(),
  },
  handler: async (ctx, args) => {
    // タスクのステータスを更新
    await ctx.db.patch(args.taskId, {
      status: args.status,
      updatedAt: Date.now(),
    });
  },
});

patch は指定したフィールドのみを更新し、他のフィールドはそのまま保持します。

削除 (Delete)

ドキュメントを削除するには、delete メソッドを使用します。

typescriptexport const deleteTask = mutation({
  args: { taskId: v.id('tasks') },
  handler: async (ctx, args) => {
    // タスクを削除
    await ctx.db.delete(args.taskId);
  },
});

シンプルですが、削除は元に戻せないため、実装時には注意が必要です。

条件付き更新

更新前に条件をチェックし、条件が満たされる場合のみ更新を実行します。

typescriptexport const completeTask = mutation({
  args: { taskId: v.id('tasks') },
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.taskId);

    // タスクが存在しない場合はエラー
    if (!task) {
      throw new Error('Task not found');
    }

    // すでに完了している場合はスキップ
    if (task.status === 'completed') {
      return { alreadyCompleted: true };
    }

    // ステータスを完了に更新
    await ctx.db.patch(args.taskId, {
      status: 'completed',
      completedAt: Date.now(),
    });

    return { alreadyCompleted: false };
  },
});

この例では、タスクの存在確認と現在のステータスをチェックしてから、更新を実行しています。

アクション (action) の基本形

インポート

アクションでは、外部通信や複雑な処理を行うため、必要に応じて追加のモジュールをインポートします。

typescriptimport { action } from './_generated/server';
import { v } from 'convex/values';
import { api } from './_generated/api';

外部 API 呼び出し

アクションの代表的な用途は、外部 API との通信です。

typescriptexport const sendNotification = action({
  args: {
    email: v.string(),
    message: v.string(),
  },
  handler: async (ctx, args) => {
    // 外部 API (例: SendGrid) を呼び出し
    const response = await fetch(
      'https://api.sendgrid.com/v3/mail/send',
      {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${process.env.SENDGRID_API_KEY}`,
        },
        body: JSON.stringify({
          personalizations: [
            {
              to: [{ email: args.email }],
            },
          ],
          from: { email: 'noreply@example.com' },
          subject: '通知',
          content: [
            {
              type: 'text/plain',
              value: args.message,
            },
          ],
        }),
      }
    );

    return { success: response.ok };
  },
});

アクション内では fetch が使えるため、外部サービスとの連携が可能です。

アクションから mutation を呼び出し

アクションから mutation を呼び出すことで、外部処理の結果をデータベースに保存できます。

typescriptexport const processPayment = action({
  args: {
    userId: v.id('users'),
    amount: v.number(),
  },
  handler: async (ctx, args) => {
    // 外部決済 API を呼び出し
    const paymentResult = await fetch(
      'https://api.stripe.com/v1/charges',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}`,
        },
        body: new URLSearchParams({
          amount: (args.amount * 100).toString(),
          currency: 'jpy',
        }),
      }
    );

    const data = await paymentResult.json();

    // 決済結果を mutation でデータベースに保存
    if (data.status === 'succeeded') {
      await ctx.runMutation(api.payments.recordPayment, {
        userId: args.userId,
        amount: args.amount,
        stripeChargeId: data.id,
      });
    }

    return { success: data.status === 'succeeded' };
  },
});

ctx.runMutation を使って、定義済みの mutation を呼び出しています。これにより、外部処理とデータベース更新を組み合わせられます。

スケジュールされたアクション

定期的に実行したい処理は、スケジュール機能を使って実装できます。

typescriptexport const dailyReport = action({
  args: {},
  handler: async (ctx, args) => {
    // 過去 24 時間のタスク統計を取得
    const stats = await ctx.runQuery(
      api.tasks.getDailyStats
    );

    // レポートを外部サービスに送信
    await fetch('https://api.slack.com/webhooks/xxx', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        text: `本日のタスク統計: 完了 ${stats.completed} 件、未完了 ${stats.pending} 件`,
      }),
    });
  },
});

このアクションは、Convex のスケジュール機能を使って毎日実行するように設定できます。

バリデーションのパターン

基本的な型

Convex では、v オブジェクトを使って引数の型を定義します。

typescriptimport { v } from 'convex/values';

export const example = query({
  args: {
    // 文字列
    name: v.string(),
    // 数値
    age: v.number(),
    // 真偽値
    isActive: v.boolean(),
    // ID
    userId: v.id('users'),
    // 省略可能
    nickname: v.optional(v.string()),
  },
  handler: async (ctx, args) => {
    // ...
  },
});

これらの基本的な型を組み合わせることで、ほとんどのユースケースに対応できます。

オブジェクトと配列

複雑な構造を持つ引数は、v.objectv.array を使って定義します。

typescriptexport const createProject = mutation({
  args: {
    project: v.object({
      name: v.string(),
      description: v.string(),
      tags: v.array(v.string()),
      metadata: v.object({
        priority: v.number(),
        dueDate: v.optional(v.number()),
      }),
    }),
  },
  handler: async (ctx, args) => {
    const projectId = await ctx.db.insert(
      'projects',
      args.project
    );
    return projectId;
  },
});

ネストしたオブジェクトや配列も、直感的に定義できます。

Union 型

複数の型のいずれかを受け付ける場合は、v.union を使用します。

typescriptexport const updateField = mutation({
  args: {
    taskId: v.id('tasks'),
    field: v.string(),
    value: v.union(v.string(), v.number(), v.boolean()),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.taskId, {
      [args.field]: args.value,
    });
  },
});

この例では、value は文字列、数値、真偽値のいずれかを受け付けます。

フロントエンドからの呼び出し

React での query 使用

React コンポーネントから query を呼び出すには、useQuery フックを使用します。

typescriptimport { useQuery } from 'convex/react';
import { api } from '../convex/_generated/api';

function TaskList() {
  const tasks = useQuery(api.tasks.listTasks);

  if (tasks === undefined) {
    return <div>読み込み中...</div>;
  }

  return (
    <ul>
      {tasks.map((task) => (
        <li key={task._id}>{task.title}</li>
      ))}
    </ul>
  );
}

useQuery は、データが変更されると自動的にコンポーネントを再レンダリングします。これが Convex のリアクティブ性の核心です。

React での mutation 使用

mutation を呼び出すには、useMutation フックを使用します。

typescriptimport { useMutation } from 'convex/react';
import { api } from '../convex/_generated/api';

function CreateTaskForm() {
  const createTask = useMutation(api.tasks.createTask);

  const handleSubmit = async (
    e: React.FormEvent<HTMLFormElement>
  ) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    await createTask({
      title: formData.get('title') as string,
      status: 'pending',
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name='title' required />
      <button type='submit'>タスク作成</button>
    </form>
  );
}

mutation は非同期関数として呼び出され、完了を待つことができます。

React での action 使用

action を呼び出すには、useAction フックを使用します。

typescriptimport { useAction } from 'convex/react';
import { api } from '../convex/_generated/api';

function NotificationButton() {
  const sendNotification = useAction(
    api.notifications.sendNotification
  );

  const handleClick = async () => {
    const result = await sendNotification({
      email: 'user@example.com',
      message: 'タスクが完了しました',
    });

    if (result.success) {
      alert('通知を送信しました');
    }
  };

  return <button onClick={handleClick}>通知送信</button>;
}

action は mutation と同様に非同期関数として呼び出されますが、リアクティブな更新は発生しません。

エラーハンドリング

query でのエラー

query でエラーが発生した場合、適切なメッセージを返すか、例外をスローします。

typescriptexport const getTask = query({
  args: { taskId: v.id('tasks') },
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.taskId);

    if (!task) {
      throw new Error(`Task not found: ${args.taskId}`);
    }

    return task;
  },
});

エラーをスローすると、フロントエンド側で catch できます。

mutation でのエラー

mutation でも同様に、エラーハンドリングを行います。

typescriptexport const updateTask = mutation({
  args: {
    taskId: v.id('tasks'),
    title: v.string(),
  },
  handler: async (ctx, args) => {
    const task = await ctx.db.get(args.taskId);

    if (!task) {
      throw new Error('タスクが見つかりません');
    }

    if (args.title.length < 3) {
      throw new Error('タイトルは 3 文字以上必要です');
    }

    await ctx.db.patch(args.taskId, { title: args.title });
  },
});

ビジネスロジックのバリデーションもここで行えます。

フロントエンド側でのエラーキャッチ

フロントエンド側では、try-catch を使ってエラーをハンドリングします。

typescriptfunction UpdateTaskForm({ taskId }: { taskId: string }) {
  const updateTask = useMutation(api.tasks.updateTask);
  const [error, setError] = React.useState<string | null>(
    null
  );

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setError(null);

    try {
      await updateTask({
        taskId: taskId as any,
        title: (e.target as any).title.value,
      });
    } catch (err) {
      setError(
        err instanceof Error
          ? err.message
          : 'エラーが発生しました'
      );
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name='title' required />
      <button type='submit'>更新</button>
      {error && <div style={{ color: 'red' }}>{error}</div>}
    </form>
  );
}

エラーメッセージをユーザーに表示することで、問題を把握しやすくなります。

パフォーマンス最適化

ペジネーション

大量のデータを扱う場合、ペジネーションを使って効率的に取得します。

typescriptexport const paginatedTasks = query({
  args: {
    paginationOpts: v.object({
      numItems: v.number(),
      cursor: v.union(v.string(), v.null()),
    }),
  },
  handler: async (ctx, args) => {
    return await ctx.db
      .query('tasks')
      .order('desc')
      .paginate(args.paginationOpts);
  },
});

paginate メソッドは、指定した件数だけデータを取得し、次のページ用のカーソルを返します。

インデックスの活用

インデックスを適切に使うことで、クエリのパフォーマンスを大幅に向上させられます。

typescript// schema.ts でインデックスを定義
import { defineSchema, defineTable } from 'convex/server';
import { v } from 'convex/values';

export default defineSchema({
  tasks: defineTable({
    title: v.string(),
    status: v.string(),
    userId: v.id('users'),
    createdAt: v.number(),
  })
    .index('by_status', ['status'])
    .index('by_user', ['userId'])
    .index('by_user_and_status', ['userId', 'status']),
});

複合インデックスを使うことで、複数条件のフィルタリングも高速化できます。

不要なデータを取得しない

必要なフィールドだけを返すことで、ネットワークトラフィックを削減できます。

typescriptexport const listTaskTitles = query({
  handler: async (ctx) => {
    const tasks = await ctx.db.query('tasks').collect();

    // タイトルだけを返す
    return tasks.map((task) => ({
      _id: task._id,
      title: task.title,
    }));
  },
});

シンプルですが、大量のデータを扱う場合は効果的です。

認証との統合

ユーザー情報の取得

認証済みユーザーの情報を取得するには、ctx.auth を使用します。

typescriptexport const getCurrentUser = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      return null;
    }

    // ユーザー情報を返す
    return {
      userId: identity.subject,
      email: identity.email,
      name: identity.name,
    };
  },
});

getUserIdentity は、認証プロバイダーから提供されたユーザー情報を返します。

認証が必要な mutation

認証されていない場合はエラーをスローすることで、セキュアな mutation を実装できます。

typescriptexport const createPrivateTask = mutation({
  args: {
    title: v.string(),
  },
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      throw new Error('認証が必要です');
    }

    const taskId = await ctx.db.insert('tasks', {
      title: args.title,
      userId: identity.subject,
      createdAt: Date.now(),
    });

    return taskId;
  },
});

この例では、認証されたユーザーのみがタスクを作成できます。

ユーザー専用データの取得

ログインユーザー自身のデータだけを取得する query の実装例です。

typescriptexport const getMyTasks = query({
  handler: async (ctx) => {
    const identity = await ctx.auth.getUserIdentity();

    if (!identity) {
      return [];
    }

    return await ctx.db
      .query('tasks')
      .withIndex('by_user', (q) =>
        q.eq('userId', identity.subject)
      )
      .collect();
  },
});

認証情報を使って、ユーザーごとにデータをフィルタリングしています。

まとめ

本記事では、Convex のクエリ、ミューテーション、アクションの基本から応用まで、チートシート形式で解説しました。

query はリアクティブなデータ取得に特化し、データベースの変更を即座にフロントエンドに反映します。mutation はデータベースへの書き込みを担当し、フォーム送信やボタンクリックなどのユーザーアクションに対応します。action は外部 API との連携や複雑な処理を実行し、必要に応じて mutation を呼び出してデータベースを更新します。

これらの使い分けを理解し、適切にバリデーションとエラーハンドリングを実装することで、型安全で堅牢なバックエンドを構築できます。インデックスやペジネーションを活用すれば、パフォーマンスも最適化できますね。

Convex の強みであるリアクティブ性を最大限に活かし、ユーザー体験の優れたアプリケーションを開発していきましょう。

関連リンク