jpskill.com
📦 その他 コミュニティ

convex-realtime

Convexのリアルタイム機能で、データが即座に更新されるようなUIや、複数人での同時編集、ページネーションなどをスムーズに実装し、ユーザー体験を向上させるSkill。

📜 元の英語説明(参考)

Realtime subscriptions and optimistic updates in Convex. Use when implementing live data updates, optimistic UI, pagination with realtime, presence indicators, typing indicators, or any feature requiring instant data synchronization.

🇯🇵 日本人クリエイター向け解説

一言でいうと

Convexのリアルタイム機能で、データが即座に更新されるようなUIや、複数人での同時編集、ページネーションなどをスムーズに実装し、ユーザー体験を向上させるSkill。

※ jpskill.com 編集部が日本のビジネス現場向けに補足した解説です。Skill本体の挙動とは独立した参考情報です。

⚡ おすすめ: コマンド1行でインストール(60秒)

下記のコマンドをコピーしてターミナル(Mac/Linux)または PowerShell(Windows)に貼り付けてください。 ダウンロード → 解凍 → 配置まで全自動。

🍎 Mac / 🐧 Linux
mkdir -p ~/.claude/skills && cd ~/.claude/skills && curl -L -o convex-realtime.zip https://jpskill.com/download/8735.zip && unzip -o convex-realtime.zip && rm convex-realtime.zip
🪟 Windows (PowerShell)
$d = "$env:USERPROFILE\.claude\skills"; ni -Force -ItemType Directory $d | Out-Null; iwr https://jpskill.com/download/8735.zip -OutFile "$d\convex-realtime.zip"; Expand-Archive "$d\convex-realtime.zip" -DestinationPath $d -Force; ri "$d\convex-realtime.zip"

完了後、Claude Code を再起動 → 普通に「動画プロンプト作って」のように話しかけるだけで自動発動します。

💾 手動でダウンロードしたい(コマンドが難しい人向け)
  1. 1. 下の青いボタンを押して convex-realtime.zip をダウンロード
  2. 2. ZIPファイルをダブルクリックで解凍 → convex-realtime フォルダができる
  3. 3. そのフォルダを C:\Users\あなたの名前\.claude\skills\(Win)または ~/.claude/skills/(Mac)へ移動
  4. 4. Claude Code を再起動

⚠️ ダウンロード・利用は自己責任でお願いします。当サイトは内容・動作・安全性について責任を負いません。

🎯 このSkillでできること

下記の説明文を読むと、このSkillがあなたに何をしてくれるかが分かります。Claudeにこの分野の依頼をすると、自動で発動します。

📦 インストール方法 (3ステップ)

  1. 1. 上の「ダウンロード」ボタンを押して .skill ファイルを取得
  2. 2. ファイル名の拡張子を .skill から .zip に変えて展開(macは自動展開可)
  3. 3. 展開してできたフォルダを、ホームフォルダの .claude/skills/ に置く
    • · macOS / Linux: ~/.claude/skills/
    • · Windows: %USERPROFILE%\.claude\skills\

Claude Code を再起動すれば完了。「このSkillを使って…」と話しかけなくても、関連する依頼で自動的に呼び出されます。

詳しい使い方ガイドを見る →
最終更新
2026-05-18
取得日時
2026-05-18
同梱ファイル
1

📖 Skill本文(日本語訳)

※ 原文(英語/中国語)を Gemini で日本語化したものです。Claude 自身は原文を読みます。誤訳がある場合は原文をご確認ください。

Convex Realtime

自動サブスクリプション

Convex のクエリは、自動的に更新をサブスクライブします。

// React component - データが変更されると自動的に更新されます
function TaskList({ userId }: { userId: Id<"users"> }) {
  const tasks = useQuery(api.tasks.list, { userId });

  if (tasks === undefined) return <Loading />;

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

楽観的更新

基本的な楽観的更新

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

function AddTask() {
  const addTask = useMutation(api.tasks.create).withOptimisticUpdate(
    (localStore, args) => {
      const { title, userId } = args;

      // ローカルキャッシュから現在のタスクを取得します
      const currentTasks = localStore.getQuery(api.tasks.list, { userId });
      if (currentTasks === undefined) return;

      // 楽観的なタスクを追加します
      const optimisticTask = {
        _id: crypto.randomUUID() as Id<"tasks">,
        _creationTime: Date.now(),
        title,
        userId,
        completed: false,
      };

      // ローカルキャッシュをすぐに更新します
      localStore.setQuery(api.tasks.list, { userId }, [
        optimisticTask,
        ...currentTasks,
      ]);
    }
  );

  return (
    <button onClick={() => addTask({ title: "New Task", userId })}>
      Add Task
    </button>
  );
}

楽観的な削除

const deleteTask = useMutation(api.tasks.remove).withOptimisticUpdate(
  (localStore, args) => {
    const { taskId, userId } = args;

    const currentTasks = localStore.getQuery(api.tasks.list, { userId });
    if (currentTasks === undefined) return;

    // ローカルキャッシュからタスクを削除します
    localStore.setQuery(
      api.tasks.list,
      { userId },
      currentTasks.filter((t) => t._id !== taskId)
    );
  }
);

楽観的なトグル

const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
  (localStore, args) => {
    const { taskId, userId } = args;

    const currentTasks = localStore.getQuery(api.tasks.list, { userId });
    if (currentTasks === undefined) return;

    // ローカルで完了ステータスを切り替えます
    localStore.setQuery(
      api.tasks.list,
      { userId },
      currentTasks.map((t) =>
        t._id === taskId ? { ...t, completed: !t.completed } : t
      )
    );
  }
);

ページネーションされたリアルタイム

// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";

export const list = query({
  args: {
    channelId: v.id("channels"),
    paginationOpts: paginationOptsValidator,
  },
  returns: v.object({
    page: v.array(v.object({
      _id: v.id("messages"),
      _creationTime: v.number(),
      content: v.string(),
      authorId: v.id("users"),
    })),
    isDone: v.boolean(),
    continueCursor: v.string(),
  }),
  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 コンポーネント
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
  const { results, status, loadMore } = usePaginatedQuery(
    api.messages.list,
    { channelId },
    { initialNumItems: 25 }
  );

  return (
    <div>
      {results.map((message) => (
        <Message key={message._id} message={message} />
      ))}

      {status === "CanLoadMore" && (
        <button onClick={() => loadMore(25)}>Load More</button>
      )}

      {status === "LoadingMore" && <Loading />}
    </div>
  );
}

プレゼンスインジケーター

スキーマ

// convex/schema.ts
export default defineSchema({
  presence: defineTable({
    odcumentId: v.string(),
    odcumentType: v.string(),
    lastSeen: v.number(),
  })
    .index("by_user", ["userId"])
    .index("by_document", ["documentId", "documentType"]),
});

プレゼンスの更新

// convex/presence.ts
export const heartbeat = mutation({
  args: {
    documentId: v.string(),
    documentType: v.string(),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    const existing = await ctx.db
      .query("presence")
      .withIndex("by_user", (q) => q.eq("userId", identity.subject))
      .filter((q) =>
        q.and(
          q.eq(q.field("documentId"), args.documentId),
          q.eq(q.field("documentType"), args.documentType)
        )
      )
      .unique();

    if (existing) {
      await ctx.db.patch(existing._id, { lastSeen: Date.now() });
    } else {
      await ctx.db.insert("presence", {
        userId: identity.subject,
        documentId: args.documentId,
        documentType: args.documentType,
        lastSeen: Date.now(),
      });
    }
    return null;
  },
});

export const getActive = query({
  args: {
    documentId: v.string(),
    documentType: v.string(),
  },
  returns: v.array(v.object({
    userId: v.string(),
    lastSeen: v.number(),
  })),
  handler: async (ctx, args) => {
    const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;

    return await ctx.db
      .query("presence")
      .withIndex("by_document", (q) =>
        q.eq("documentId", args.documentId).eq("documentType", args.documentType)
      )
      .filter((q) => q.gt(q.field("lastSeen"), fiveMinutesAgo))
      .collect();
  },
});

クライアントフック


function usePresence(documentId: string, documentType: string) {
  const heartbeat = useMutation(api.presence.heartbeat);
  const activeUsers = useQuery(api.presence.getActive, {
    documentId,
    documentType,
  });

  useEffect(() => {
    // 30秒ごとに heartbeat を送信します
    const interval = setInterval(() => {
      heartbeat({ documentId, documentType });
    }, 30

(原文がここで切り詰められています)
📜 原文 SKILL.md(Claudeが読む英語/中国語)を展開

Convex Realtime

Automatic Subscriptions

Queries in Convex automatically subscribe to updates:

// React component - automatically updates when data changes
function TaskList({ userId }: { userId: Id<"users"> }) {
  const tasks = useQuery(api.tasks.list, { userId });

  if (tasks === undefined) return <Loading />;

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

Optimistic Updates

Basic Optimistic Update

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

function AddTask() {
  const addTask = useMutation(api.tasks.create).withOptimisticUpdate(
    (localStore, args) => {
      const { title, userId } = args;

      // Get current tasks from local cache
      const currentTasks = localStore.getQuery(api.tasks.list, { userId });
      if (currentTasks === undefined) return;

      // Add optimistic task
      const optimisticTask = {
        _id: crypto.randomUUID() as Id<"tasks">,
        _creationTime: Date.now(),
        title,
        userId,
        completed: false,
      };

      // Update local cache immediately
      localStore.setQuery(api.tasks.list, { userId }, [
        optimisticTask,
        ...currentTasks,
      ]);
    }
  );

  return (
    <button onClick={() => addTask({ title: "New Task", userId })}>
      Add Task
    </button>
  );
}

Optimistic Delete

const deleteTask = useMutation(api.tasks.remove).withOptimisticUpdate(
  (localStore, args) => {
    const { taskId, userId } = args;

    const currentTasks = localStore.getQuery(api.tasks.list, { userId });
    if (currentTasks === undefined) return;

    // Remove task from local cache
    localStore.setQuery(
      api.tasks.list,
      { userId },
      currentTasks.filter((t) => t._id !== taskId)
    );
  }
);

Optimistic Toggle

const toggleTask = useMutation(api.tasks.toggle).withOptimisticUpdate(
  (localStore, args) => {
    const { taskId, userId } = args;

    const currentTasks = localStore.getQuery(api.tasks.list, { userId });
    if (currentTasks === undefined) return;

    // Toggle completed status locally
    localStore.setQuery(
      api.tasks.list,
      { userId },
      currentTasks.map((t) =>
        t._id === taskId ? { ...t, completed: !t.completed } : t
      )
    );
  }
);

Paginated Realtime

// convex/messages.ts
import { query } from "./_generated/server";
import { v } from "convex/values";
import { paginationOptsValidator } from "convex/server";

export const list = query({
  args: {
    channelId: v.id("channels"),
    paginationOpts: paginationOptsValidator,
  },
  returns: v.object({
    page: v.array(v.object({
      _id: v.id("messages"),
      _creationTime: v.number(),
      content: v.string(),
      authorId: v.id("users"),
    })),
    isDone: v.boolean(),
    continueCursor: v.string(),
  }),
  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 component with pagination
function MessageList({ channelId }: { channelId: Id<"channels"> }) {
  const { results, status, loadMore } = usePaginatedQuery(
    api.messages.list,
    { channelId },
    { initialNumItems: 25 }
  );

  return (
    <div>
      {results.map((message) => (
        <Message key={message._id} message={message} />
      ))}

      {status === "CanLoadMore" && (
        <button onClick={() => loadMore(25)}>Load More</button>
      )}

      {status === "LoadingMore" && <Loading />}
    </div>
  );
}

Presence Indicators

Schema

// convex/schema.ts
export default defineSchema({
  presence: defineTable({
    odcumentId: v.string(),
    odcumentType: v.string(),
    lastSeen: v.number(),
  })
    .index("by_user", ["userId"])
    .index("by_document", ["documentId", "documentType"]),
});

Update Presence

// convex/presence.ts
export const heartbeat = mutation({
  args: {
    documentId: v.string(),
    documentType: v.string(),
  },
  returns: v.null(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    const existing = await ctx.db
      .query("presence")
      .withIndex("by_user", (q) => q.eq("userId", identity.subject))
      .filter((q) =>
        q.and(
          q.eq(q.field("documentId"), args.documentId),
          q.eq(q.field("documentType"), args.documentType)
        )
      )
      .unique();

    if (existing) {
      await ctx.db.patch(existing._id, { lastSeen: Date.now() });
    } else {
      await ctx.db.insert("presence", {
        userId: identity.subject,
        documentId: args.documentId,
        documentType: args.documentType,
        lastSeen: Date.now(),
      });
    }
    return null;
  },
});

export const getActive = query({
  args: {
    documentId: v.string(),
    documentType: v.string(),
  },
  returns: v.array(v.object({
    userId: v.string(),
    lastSeen: v.number(),
  })),
  handler: async (ctx, args) => {
    const fiveMinutesAgo = Date.now() - 5 * 60 * 1000;

    return await ctx.db
      .query("presence")
      .withIndex("by_document", (q) =>
        q.eq("documentId", args.documentId).eq("documentType", args.documentType)
      )
      .filter((q) => q.gt(q.field("lastSeen"), fiveMinutesAgo))
      .collect();
  },
});

Client Hook

function usePresence(documentId: string, documentType: string) {
  const heartbeat = useMutation(api.presence.heartbeat);
  const activeUsers = useQuery(api.presence.getActive, {
    documentId,
    documentType,
  });

  useEffect(() => {
    // Send heartbeat every 30 seconds
    const interval = setInterval(() => {
      heartbeat({ documentId, documentType });
    }, 30000);

    // Initial heartbeat
    heartbeat({ documentId, documentType });

    return () => clearInterval(interval);
  }, [documentId, documentType, heartbeat]);

  return activeUsers ?? [];
}

Typing Indicators

// convex/typing.ts
export const setTyping = mutation({
  args: { channelId: v.id("channels"), isTyping: v.boolean() },
  returns: v.null(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) return null;

    const existing = await ctx.db
      .query("typing")
      .withIndex("by_channel_user", (q) =>
        q.eq("channelId", args.channelId).eq("userId", identity.subject)
      )
      .unique();

    if (args.isTyping) {
      if (existing) {
        await ctx.db.patch(existing._id, { updatedAt: Date.now() });
      } else {
        await ctx.db.insert("typing", {
          channelId: args.channelId,
          userId: identity.subject,
          updatedAt: Date.now(),
        });
      }
    } else if (existing) {
      await ctx.db.delete(existing._id);
    }
    return null;
  },
});

export const getTyping = query({
  args: { channelId: v.id("channels") },
  returns: v.array(v.string()),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    const tenSecondsAgo = Date.now() - 10000;

    const typing = await ctx.db
      .query("typing")
      .withIndex("by_channel", (q) => q.eq("channelId", args.channelId))
      .filter((q) => q.gt(q.field("updatedAt"), tenSecondsAgo))
      .collect();

    // Exclude current user
    return typing
      .filter((t) => t.userId !== identity?.subject)
      .map((t) => t.userId);
  },
});

Conditional Queries

function UserProfile({ userId }: { userId: Id<"users"> | null }) {
  // Query only runs when userId is not null
  const user = useQuery(
    api.users.get,
    userId ? { userId } : "skip"
  );

  if (userId === null) return <GuestView />;
  if (user === undefined) return <Loading />;

  return <ProfileView user={user} />;
}

Common Pitfalls

  • Stale optimistic updates - Always verify server state matches expected
  • Over-subscribing - Only subscribe to data you need
  • Missing loading states - Handle undefined (loading) vs null (not found)
  • Presence cleanup - Add scheduled job to clean old presence records

References