YugenYugen

Example: Queries vs Mutations

Understanding when to use queries vs mutations in Convex and how they handle real-time updates

This learning was documented on 10th October 2025, while implementing the notification system (e.g. see Feature 1). I was struggling to understand when to use queries vs mutations and how Convex handles real-time updates automatically.

The Problem I Encountered

While building the notification system, I was confused about when to use query vs mutation in Convex. I kept getting errors and wasn't sure why some functions updated the UI automatically while others didn't.

Key Learning: Queries vs Mutations

What Are Queries?

Queries are read-only functions that fetch data from your database. They automatically subscribe to changes and update your React components in real-time.

What Are Mutations?

Mutations are write-only functions that modify data in your database. They don't return data directly, but they trigger queries to re-run automatically.

Visual Understanding

Code Examples

❌ Wrong Approach (What I Was Doing)

// This doesn't work - trying to return data from a mutation
export const createNotification = mutation({
  args: {
    title: v.string(),
    message: v.string(),
  },
  handler: async (ctx, args) => {
    const id = await ctx.db.insert("notifications", {
      ...args,
      isRead: false,
      createdAt: Date.now()
    });
    
    // ❌ This won't work - mutations don't return data to React
    return await ctx.db.get(id);
  },
});

✅ Correct Approach

// Mutation: Only modifies data
export const createNotification = mutation({
  args: {
    title: v.string(),
    message: v.string(),
  },
  handler: async (ctx, args) => {
    // ✅ Just insert the data
    await ctx.db.insert("notifications", {
      ...args,
      isRead: false,
      createdAt: Date.now()
    });
    // No return needed - queries will automatically update
  },
});

// Query: Fetches and subscribes to data
export const getNotifications = query({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    // ✅ This automatically updates React components
    return await ctx.db
      .query("notifications")
      .withIndex("by_user", (q) => q.eq("userId", args.userId))
      .collect();
  },
});

Real-time Update Flow

Key Patterns I Learned

1. Always Use Queries for Data Fetching

// ✅ Good: Query for fetching data
export const getUserProfile = query({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId);
  },
});

// ❌ Bad: Mutation for fetching data
export const getUserProfile = mutation({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    return await ctx.db.get(args.userId); // Won't update React automatically
  },
});

2. Use Mutations for Data Changes

// ✅ Good: Mutation for updating data
export const updateUserProfile = mutation({
  args: {
    userId: v.id("users"),
    name: v.string(),
  },
  handler: async (ctx, args) => {
    await ctx.db.patch(args.userId, { name: args.name });
  },
});

3. Combine Both in React Components

// ✅ Good: Use both query and mutation
export function NotificationList() {
  // Query: Automatically updates when data changes
  const notifications = useQuery(api.notifications.getNotifications, { 
    userId: "user_123" 
  });
  
  // Mutation: Triggers query to re-run
  const markAsRead = useMutation(api.notifications.markAsRead);
  
  const handleMarkAsRead = async (id: Id<"notifications">) => {
    await markAsRead({ notificationId: id });
    // Query automatically re-runs and updates the UI
  };
  
  return (
    <div>
      {notifications?.map(notification => (
        <div key={notification._id}>
          {notification.title}
          <button onClick={() => handleMarkAsRead(notification._id)}>
            Mark as Read
          </button>
        </div>
      ))}
    </div>
  );
}

Common Mistakes to Avoid

1. Returning Data from Mutations

// ❌ Don't do this
export const createUser = mutation({
  handler: async (ctx, args) => {
    const id = await ctx.db.insert("users", args);
    return await ctx.db.get(id); // This won't work as expected
  },
});

2. Using Queries for Data Modification

// ❌ Don't do this
export const updateUser = query({
  handler: async (ctx, args) => {
    await ctx.db.patch(args.id, args); // Queries should be read-only
    return await ctx.db.get(args.id);
  },
});

3. Not Understanding Automatic Updates

// ❌ Don't manually refetch after mutations
const markAsRead = useMutation(api.notifications.markAsRead);
const refetch = useQuery(api.notifications.getNotifications, { userId });

const handleMarkAsRead = async (id) => {
  await markAsRead({ notificationId: id });
  // ❌ Don't do this - it's automatic!
  await refetch();
};

Performance Considerations

Query Optimization

// ✅ Use indexes for better performance
export const getNotifications = query({
  args: { userId: v.id("users") },
  handler: async (ctx, args) => {
    return await ctx.db
      .query("notifications")
      .withIndex("by_user", (q) => q.eq("userId", args.userId)) // Use index
      .filter((q) => q.eq(q.field("isRead"), false)) // Additional filtering
      .take(10); // Limit results
  },
});

Mutation Batching

// ✅ Batch multiple operations in one mutation
export const markMultipleAsRead = mutation({
  args: { notificationIds: v.array(v.id("notifications")) },
  handler: async (ctx, args) => {
    // Batch all updates in one transaction
    await Promise.all(
      args.notificationIds.map(id => 
        ctx.db.patch(id, { isRead: true })
      )
    );
  },
});

When to Use Each

Use CaseFunction TypeReason
Fetch user dataQueryNeed real-time updates
Display notificationsQueryWant automatic UI updates
Create new notificationMutationModifying database
Update user profileMutationChanging data
Delete itemMutationRemoving data
Search/filter dataQueryReading data with filters

Key Takeaways

  1. Queries = Read + Real-time updates
  2. Mutations = Write + Trigger queries to re-run
  3. Never return data from mutations - let queries handle fetching
  4. Use indexes for better query performance
  5. Batch operations in mutations when possible
  6. Convex handles real-time updates automatically - don't manually refetch

On this page