Skip to content

get-convex/reactions

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

32 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Convex Component Template

This is a Convex component, ready to be published on npm.

To create your own component:

  1. Run node rename.mjs to rename everything to your component's name.
  2. Write code in src/component for your component.
  3. Write code in src/client for the Class that interfaces with the component.
  4. Write example usage in example/convex/example.ts.
  5. Delete the text in this readme until --- and flesh out the README.
  6. Publish to npm with npm run alpha or npm run release.

To develop your component run a dev process in the example project:

npm i
npm run dev

npm 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.

Component Directory structure

.
β”œβ”€β”€ 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.

Convex Reactions Component

npm version

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.

Pre-requisite: Convex

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.

Installation

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/reactions

Create 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;

Usage

Basic Setup

import { components } from "./_generated/api";
import { Reactions } from "@convex/reactions";

const reactions = new Reactions(components.reactions);

Important: Default Behavior - One Reaction Per User

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:

  1. Use different namespaces for each reaction category (recommended for different types of reactions)
  2. Set allowMultipleReactions: true to allow multiple reactions in the same namespace (see below)

Add a Reaction

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;
  },
});

Allow Multiple Reactions Per User

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 Reaction

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 Reaction Counts

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 }]

Batch Get Reaction Counts

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.

Check User's Reactions

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 with

Check Specific Reaction

Check 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);
  },
});

Using Namespaces

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.

Cascade Deletion

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.

API Reference

Methods

All methods accept an optional namespace parameter to scope reactions to different contexts.

add(ctx, targetId, label, userId, namespace?, allowMultipleReactions?)

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 namespace
  • allowMultipleReactions (optional): If true, allows users to have multiple different reactions on the same target. Defaults to false.
  • Returns: null

remove(ctx, targetId, label, userId, namespace?)

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

getCounts(ctx, targetId, namespace?)

Get aggregated reaction counts for a target.

  • namespace (optional): Filter to a specific namespace
  • Returns: Array<{ label: string, count: number }>

getBatchCounts(ctx, targets)

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 }] }
// ]

list(ctx, targetId, namespace?)

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

getUserReactions(ctx, targetId, 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

hasUserReacted(ctx, targetId, label, userId, namespace?)

Check if a user has reacted with a specific reaction type.

  • namespace (optional): Filter to a specific namespace
  • Returns: boolean

deleteAllForTarget(ctx, targetId, namespace?)

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;
  },
});

Re-exporting the API

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.

HTTP Endpoints

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

Performance Tips

Use Batch Operations for Lists

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.

Frontend Usage

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}
    />
  ));
}

Data Model

The component uses two tables:

  • reactions: Individual reactions (one per user + target + label)

    • targetId: string - the ID of the thing being reacted to
    • label: string - the reaction (e.g., "πŸ‘", "❀️")
    • userId: string - who reacted
  • reactionCounts: Denormalized aggregates for fast queries

    • targetId: string
    • label: string
    • count: 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

About

Component for reactions

Resources

License

Contributing

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors