This is a Convex component, ready to be published on npm.
To create your own component:
- Run
node rename.mjsto rename everything to your component's name. - Write code in src/component for your component.
- Write code in src/client for the Class that interfaces with the component.
- Write example usage in example/convex/example.ts.
- Delete the text in this readme until
---and flesh out the README. - Publish to npm with
npm run alphaornpm run release.
To develop your component run a dev process in the example project:
npm i
npm run devnpm i will do the install and an initial build. npm run dev will start a
file watcher to re-build the component, as well as the example project frontend
and backend, which does codegen and installs the component.
Modify the schema and index files in src/component/ to define your component.
Write a client for using this component in src/client/index.ts.
If you won't be adding frontend code (e.g. React components) to this component you can delete the following:
- "./react" exports in package.json
- the "src/react/" directory
If you will be adding frontend code, add a peer dependency on React in package.json.
.
βββ README.md documentation of your component
βββ package.json component name, version number, other metadata
βββ package-lock.json Components are like libraries, package-lock.json
β is .gitignored and ignored by consumers.
βββ src
βΒ Β βββ component/
βΒ Β βΒ Β βββ _generated/ Files here are generated for the component.
βΒ Β βΒ Β βββ convex.config.ts Name your component here and use other components
βΒ Β βΒ Β βββ lib.ts Define functions here and in new files in this directory
βΒ Β βΒ Β βββ schema.ts schema specific to this component
βΒ Β βββ client/index.ts "Thick" client code goes here.
βΒ Β βββ react/ Code intended to be used on the frontend goes here.
β β Your are free to delete this if this component
β β does not provide code.
βΒ Β βββ index.ts
βββ example/ example Convex app that uses this component
βΒ Β βββ convex/
βΒ Β Β Β βββ _generated/ Files here are generated for the example app.
βΒ Β Β Β βββ convex.config.ts Imports and uses this component
βΒ Β Β Β βββ myFunctions.ts Functions that use the component
βΒ Β Β Β βββ schema.ts Example app schema
βββ dist/ Publishing artifacts will be created here.
A Convex component for adding reactions (like emojis πβ€οΈπ) to any content in your app. Perfect for social features, posts, comments, or any content that users can react to.
Features:
- β Idempotent add and remove operations
- β Denormalized counts for fast aggregation
- β Track which users reacted with what
- β Support for arbitrary reaction types (emojis, custom reactions, etc.)
- β All operations are idempotent (safe to call multiple times)
- β One reaction per user per target+namespace - changing reactions automatically removes the previous one
- β Multiple reactions per user - optional mode to allow users to have multiple different reactions on the same target
- β Batch operations - efficiently get counts for multiple targets in a single query
- β Cascade deletion - easily delete all reactions when content is removed
Found a bug? Feature request? File it here.
You'll need an existing Convex project to use the component. Convex is a hosted backend platform, including a database, serverless functions, and a ton more you can learn about here.
Run npm create convex or follow any of the
quickstarts to set one up.
The react component is not available on npm, but is a reference of how to set up a component to be published.
Example of how to Install the component package (if it were published):
npm install @example_organziation/reactionsCreate a convex.config.ts file in your app's convex/ folder and install the
component by calling use:
// convex/convex.config.ts
import { defineApp } from "convex/server";
import reactions from "@convex/reactions/convex.config.js";
const app = defineApp();
app.use(reactions);
export default app;import { components } from "./_generated/api";
import { Reactions } from "@convex/reactions";
const reactions = new Reactions(components.reactions);By default, each user can only have one reaction per target+namespace. When a user reacts with a different emoji, their previous reaction is automatically removed and the counts are updated:
// User reacts with π
await reactions.add(ctx, "post-1", "π", "user-1");
// Counts: π: 1
// User changes to β€οΈ - their π is automatically removed
await reactions.add(ctx, "post-1", "β€οΈ", "user-1");
// Counts: β€οΈ: 1 (π count went to 0)This default behavior makes the component perfect for:
- Single-choice reactions (like/unlike, upvote/downvote)
- Emoji reactions where users pick one emoji
- Rating systems where users can change their rating
- Voting systems where users can change their vote
Need multiple reactions per user?
You have two options:
- Use different namespaces for each reaction category (recommended for different types of reactions)
- Set
allowMultipleReactions: trueto allow multiple reactions in the same namespace (see below)
Add a reaction to a target. If the user already has a different reaction on this target, it will be replaced automatically:
import { mutation } from "./_generated/server";
import { v } from "convex/values";
export const addReaction = mutation({
args: {
postId: v.string(),
emoji: v.string(),
userId: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await reactions.add(ctx, args.postId, args.emoji, args.userId);
return null;
},
});By default, each user can only have one reaction per target. To allow a user to
have multiple different reactions on the same target, set
allowMultipleReactions to true:
export const addMultipleReaction = mutation({
args: {
postId: v.string(),
emoji: v.string(),
userId: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await reactions.add(
ctx,
args.postId,
args.emoji,
args.userId,
undefined, // namespace
true, // allowMultipleReactions
);
return null;
},
});Behavior with allowMultipleReactions: true:
// User adds thumbs up
await reactions.add(ctx, "post-1", "π", "user-1", undefined, true);
// Counts: π: 1
// User also adds heart - previous reaction is NOT removed
await reactions.add(ctx, "post-1", "β€οΈ", "user-1", undefined, true);
// Counts: π: 1, β€οΈ: 1
// User adds heart again - no-op (idempotent)
await reactions.add(ctx, "post-1", "β€οΈ", "user-1", undefined, true);
// Counts: π: 1, β€οΈ: 1 (unchanged)Use cases for multiple reactions:
- Social media posts where users can express multiple emotions (like Slack's reactions)
- Content tagging where users can add multiple labels
- Multi-dimensional feedback where users rate different aspects
- Collaborative boards where team members can add multiple stickers/badges
When to use single vs. multiple reactions:
- Single reaction (default): Like/unlike, upvote/downvote, rating systems where users pick one option
- Multiple reactions: When users should be able to express multiple emotions or tag content with multiple labels simultaneously
Remove a specific reaction from a target:
export const removeReaction = mutation({
args: {
postId: v.string(),
emoji: v.string(),
userId: v.string(),
},
returns: v.null(),
handler: async (ctx, args) => {
await reactions.remove(ctx, args.postId, args.emoji, args.userId);
return null;
},
});Get aggregated counts for all reactions on a target:
import { query } from "./_generated/server";
import { v } from "convex/values";
export const getPostReactions = query({
args: { postId: v.string() },
returns: v.array(
v.object({
label: v.string(),
count: v.number(),
}),
),
handler: async (ctx, args) => {
return await reactions.getCounts(ctx, args.postId);
},
});
// Returns: [{ label: "π", count: 5 }, { label: "β€οΈ", count: 3 }]For better performance when displaying multiple items (like a feed of posts),
use getBatchCounts to get reaction counts for multiple targets in a single
query. This is much more efficient than calling getCounts multiple times, as
it reduces overhead from crossing the component isolation boundary:
export const getBatchPostReactions = query({
args: { postIds: v.array(v.string()) },
returns: v.array(
v.object({
targetId: v.string(),
namespace: v.optional(v.string()),
counts: v.array(
v.object({
label: v.string(),
count: v.number(),
}),
),
}),
),
handler: async (ctx, args) => {
const targets = args.postIds.map((postId) => ({ targetId: postId }));
return await reactions.getBatchCounts(ctx, targets);
},
});Returns:
[
{
targetId: "post-1",
counts: [
{ label: "π", count: 5 },
{ label: "β€οΈ", count: 3 },
],
},
{
targetId: "post-2",
counts: [{ label: "π", count: 2 }],
},
{
targetId: "post-3",
counts: [], // No reactions yet
},
];You can also pass different namespaces for each target:
const targets = [
{ targetId: "post-1" },
{ targetId: "post-2", namespace: "sentiment" },
{ targetId: "post-3", namespace: "quality" },
];
return await reactions.getBatchCounts(ctx, targets);Performance tip: When rendering a list of items (posts, comments, etc.),
always prefer getBatchCounts over multiple getCounts calls. Component
boundaries have overhead, and batching reduces this significantly.
See what reactions a user has made on a specific target:
export const getUserReactions = query({
args: {
postId: v.string(),
userId: v.string(),
},
returns: v.array(v.string()),
handler: async (ctx, args) => {
return await reactions.getUserReactions(ctx, args.postId, args.userId);
},
});
// Returns: ["π", "β€οΈ"] - the emojis this user has reacted withCheck if a user has reacted with a specific reaction:
export const hasUserLiked = query({
args: {
postId: v.string(),
userId: v.string(),
},
returns: v.boolean(),
handler: async (ctx, args) => {
return await reactions.hasUserReacted(ctx, args.postId, "π", args.userId);
},
});Namespaces allow you to have multiple independent reaction systems on the same target. For example, you might want both "sentiment reactions" (πβ€οΈ) and "quality ratings" (β) on the same post:
// Sentiment reactions (user can have one in "sentiment" namespace)
await reactions.add(ctx, "post-1", "π", "user-1", "sentiment");
// User changes their mind - this replaces π in the "sentiment" namespace
await reactions.add(ctx, "post-1", "β€οΈ", "user-1", "sentiment");
// Quality rating (separate namespace - can exist simultaneously)
await reactions.add(ctx, "post-1", "β", "user-1", "quality");
// Get counts for each namespace
const sentimentCounts = await reactions.getCounts(ctx, "post-1", "sentiment");
// Returns: [{ label: "β€οΈ", count: 1 }] - only the latest sentiment
const qualityCounts = await reactions.getCounts(ctx, "post-1", "quality");
// Returns: [{ label: "β", count: 1 }]Without a namespace (or undefined), all reactions are in the default
namespace. Namespaces ensure users can only react once per
targetId + namespace combination.
When deleting content that has reactions (like posts or comments), you should
also delete all associated reactions. The deleteAllForTarget() method makes
this easy:
export const deletePost = mutation({
args: { postId: v.id("posts") },
returns: v.null(),
handler: async (ctx, args) => {
// First, delete all reactions for this post
await reactions.deleteAllForTarget(ctx, args.postId);
// Then delete the post itself
await ctx.db.delete(args.postId);
return null;
},
});This ensures that:
- No orphaned reaction data remains in your database
- Reaction counts are properly cleaned up
- All namespaces for that target are handled automatically (unless you specify a namespace parameter)
The example app demonstrates this pattern with a posts table that supports cascade deletion.
All methods accept an optional namespace parameter to scope reactions to
different contexts.
Add a reaction. If the user already has this exact reaction, this is a no-op. By default, any existing reactions by this user on the target+namespace will be removed first, then this reaction will be added.
namespace(optional): Scope reactions to a specific namespaceallowMultipleReactions(optional): Iftrue, allows users to have multiple different reactions on the same target. Defaults tofalse.- Returns:
null
Remove a reaction (idempotent - safe to call multiple times).
namespace(optional): Scope reactions to a specific namespace- Returns:
{ removed: boolean }- false if didn't exist
Get aggregated reaction counts for a target.
namespace(optional): Filter to a specific namespace- Returns:
Array<{ label: string, count: number }>
Get aggregated reaction counts for multiple targets in a single query. This is
more efficient than calling getCounts multiple times as it reduces overhead
from crossing the component isolation boundary.
targets: Array of{ targetId: string, namespace?: string }- The targets to get counts for- Returns:
Array<{ targetId: string, namespace?: string, counts: Array<{ label: string, count: number }> }>
Example:
const results = await reactions.getBatchCounts(ctx, [
{ targetId: "post-1" },
{ targetId: "post-2", namespace: "sentiment" },
]);
// Returns:
// [
// { targetId: "post-1", counts: [{ label: "π", count: 5 }] },
// { targetId: "post-2", namespace: "sentiment", counts: [{ label: "β€οΈ", count: 2 }] }
// ]Get all individual reaction documents for a target.
namespace(optional): Filter to a specific namespace- Returns: Array of reaction documents with
_id,_creationTime,targetId,label,userId,namespace
Get all reaction types a user has used on a target.
namespace(optional): Filter to a specific namespace- Returns:
string[]- array of reaction types
Check if a user has reacted with a specific reaction type.
namespace(optional): Filter to a specific namespace- Returns:
boolean
Delete all reactions for a target. This is useful for cascading deletes when removing content that has reactions.
namespace(optional): Only delete reactions in this namespace- Returns:
null
Example usage when deleting a post:
export const deletePost = mutation({
args: { postId: v.id("posts") },
returns: v.null(),
handler: async (ctx, args) => {
// First, delete all reactions for this post
await reactions.deleteAllForTarget(ctx, args.postId);
// Then delete the post itself
await ctx.db.delete(args.postId);
return null;
},
});You can directly re-export the component's API for convenience:
export const {
add,
remove,
getCounts,
getBatchCounts,
list,
getUserReactions,
hasUserReacted,
deleteAllForTarget,
} = reactions.api();This allows clients to call these functions directly without wrapping them.
You can expose reaction counts via HTTP for use in external applications,
webhooks, or public APIs. The Reactions class provides a registerRoutes()
method to easily add HTTP endpoints.
First, export your Reactions instance (e.g., in convex/reactions.ts or any
file):
import { components } from "./_generated/api";
import { Reactions } from "@convex/reactions";
export const reactions = new Reactions(components.reactions, {});Then create an http.ts file in your convex/ folder and register the routes:
import { httpRouter } from "convex/server";
import { reactions } from "./reactions";
const http = httpRouter();
// Register the reactions HTTP endpoint
reactions.registerRoutes(http, {
path: "/reactions/getCounts",
});
export default http;This creates a public endpoint at
https://your-deployment.convex.site/reactions/getCounts?targetId=post-1 that
returns JSON like:
[
{ "label": "π", "count": 5 },
{ "label": "β€οΈ", "count": 3 }
]You can also include an optional namespace query parameter:
/reactions/getCounts?targetId=post-1&namespace=sentiment
When rendering a list of items (like a feed of posts), always use
getBatchCounts instead of multiple getCounts calls:
// β BAD: Multiple component boundary crossings
const posts = await ctx.db.query("posts").take(10);
const postsWithReactions = await Promise.all(
posts.map(async (post) => ({
...post,
reactions: await reactions.getCounts(ctx, post._id),
})),
);
// β
GOOD: Single component boundary crossing
const posts = await ctx.db.query("posts").take(10);
const postIds = posts.map((p) => p._id);
const batchResults = await reactions.getBatchCounts(
ctx,
postIds.map((id) => ({ targetId: id })),
);
// Map results back to posts
const postsWithReactions = posts.map((post) => ({
...post,
reactions: batchResults.find((r) => r.targetId === post._id)?.counts || [],
}));Why this matters: Components run in isolated environments. Each call across the component boundary has overhead. Batching reduces N calls to 1 call, significantly improving performance for lists.
In React, consider batching at the query level rather than calling individual queries:
// β BAD: Each Post component makes its own query
function Post({ postId }) {
const reactions = useQuery(api.example.getPostReactions, { postId });
// ...
}
// β
GOOD: Parent component batches all queries
function PostList() {
const posts = useQuery(api.posts.list);
const postIds = posts?.map((p) => p._id) || [];
const allReactions = useQuery(api.example.getBatchPostReactions, { postIds });
return posts?.map((post) => (
<Post
key={post._id}
post={post}
reactions={allReactions?.find((r) => r.targetId === post._id)?.counts}
/>
));
}The component uses two tables:
-
reactions: Individual reactions (one per user + target + label)
targetId: string - the ID of the thing being reacted tolabel: string - the reaction (e.g., "π", "β€οΈ")userId: string - who reacted
-
reactionCounts: Denormalized aggregates for fast queries
targetId: stringlabel: stringcount: number
See more example usage in example.ts for reactions usage and posts.ts for post management with cascade deletion.
Run the example:
npm i
npm run dev