Skip to main content

2: Convex and your app

You are here

Convex Tour

  1. The reactor
  2. Convex and your app

    Learn how to connect your project to Convex and quickly build out new fullstack features

  3. The platform in action

The Convex Reactor diagram

In Part 1 you learned the basics of tables, documents, queries, and mutations, and took a first glance at the data and functions for the Convex chat app.

In this module, you'll dive deeper into the codebase to learn how to:

  • Iterate on the project's schema as your data model changes
  • Edit and add functions to implement desired business logic
  • Use the Convex React client to call functions from your UI

Starting with the (now smiley-ful!) chat app from Part 1, in this module you'll implement a new feature: a heart button that lets users "like" a message.

The chat app, now with a 'like' button on each message

Before you begin: explore the codebase

Take a moment to familiarize yourself with the codebase structure and contents. Most importantly:

  • src/ contains the frontend source code:
    • src/main.tsx sets up the React application and the ConvexReactClient context that connects the frontend to your Convex project
    • src/App.tsx contains a single <App> React component, which defines the entire UI for this simple project
    • src/index.css contains UI styles that have been included for your convenience (feel free to tweak them!)
  • convex/ contains everything related to the Convex backend, namely:
    • convex/schema.ts defines the app data model (tables and documents)
    • convex/messages.ts defines the query and mutation functions to list all messages and send a new message
    • convex/_generated/ is where Convex stores the types and utility functions it auto-generates based on your schema and function definitions. You'll never need to edit the code in this directory, but you will be importing it elsewhere in your codebase.

Coding with the Convex client

Open the convex-tour-chat repo in your IDE/editor of choice, and let's take a closer look at the Convex-related code in src/App.tsx.

The App module imports query and mutation hooks from the convex/react client library, as well as the typed api that Convex auto-generates from your function code:

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

In the <App> component, the query/mutation hooks are called with specific functions defined in the convex/ directory (the list and send functions you saw in Part 1). Those functions are accessed via the api object using the <module>.<function> naming convention:

const messages = useQuery(api.messages.list);
const sendMessage = useMutation(api.messages.send);

The reactive messages variable contains whatever data is returned from the list function (or undefined while data is loading). Similar to a state variable created with useState, any time the query's data changes, the messages value will change and the component will re-render - all thanks to Convex's built-in reactivity.

As for sendMessage, its value will be a typed, asynchronous function you can call to run the corresponding send mutation. In this case, sendMessage is called within the onSubmit handler of the form:

onSubmit={async (e) => {
e.preventDefault();
await sendMessage({ body: newMessageText, author: NAME });
setNewMessageText("");
}}

Whatever arguments the send mutation needs will also be required by the sendMessage async function; in this case, an object specifying the body and author of the new message to be added.

Further Reading: React Client Hooks

Read more about the React client hooks:

Implement a new feature

Now that you've taken a glance at how the Convex client connects to the UI, it's time to jump into the deep end and implement a whole new feature: "like" buttons!

To get this functionality working, you'll need a few things:

  • A data model for "likes" that relates each one to a specific message
  • A mutation function that saves a new like to the database
  • A button in the UI that triggers the function
  • A query that counts how many times a message has been liked

If you're not already running the development server, do so now with the command

npm run dev

Refine the data model

In order to track who has liked which messages, let's set up a new likes table in the database. Each document in the table will have a liker field that stores the given user's name as a string, and a messageId field that links to a document in the messages table.

We'll be changing the schema already declared in convex/schema.ts, but you should know that unlike traditional databases, Convex doesn't require the schema to be declared up-front. This is helpful for prototyping. That said, if you're using TypeScript as we are in this tour, you will want to define your schema so that your code is well-typed end-to-end.

Exercise 2.1

Add a 'likes' table to the database schema

Define the new table and its fields in the Schema declared in convex/schema.ts, below the existing messages table definition.

Using messages as a syntax guide, you'll specify the data model using the defineTable function as well as field Validators like v.string() and v.id("table name").

Solution
convex/schema.ts
export default defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}),
likes: defineTable({
liker: v.string(),
messageId: v.id("messages"),
}),
});

When you save your changes to schema.ts, Convex will sync the new schema with your project and create the likes table as specified. But there's no data yet, let's fix that!

Add a function for new business logic

Time to populate your new table. While you could go into the dashboard and add likes documents manually, ultimately you'll want a mutation function that can create new documents as needed based on user interaction.

Exercise 2.2

Define a 'like' mutation

Export a new mutation() called like from convex/messages.ts.

Using the send function as an example, pass in an object to the mutation() constructor with two properties:

  • args: an object defining the names and values of the function's arguments (this function should accept a liker string and a messageId id to match the shape of the likes table).
  • handler: an async function accepting two arguments, a context object you can call ctx and an arguments object matching the signature defined in args, from which you can destructure the individual arguments. Leave the body of the function empty for now.
Solution
convex/messages.ts
export const like = mutation({
args: { liker: v.string(), messageId: v.id("messages") },
handler: async (ctx, args) => {
// TODO
},
});

Great! Now, time to actually do something in the mutation's handler function.

The first argument Convex passes to the handler is a MutationContext object, which gives the function access to your Convex database through its db property. The ctx.db.insert() method lets you add a new document to a particular table.

Exercise 2.3

Insert a new 'likes' document

In the like handler, use the ctx.db object to insert a new (empty) document into the likes table, with the liker and messageId passed in as arguments. Refer to the send handler for an example.

Solution
convex/messages.ts
export const like = mutation({
args: { liker: v.string(), messageId: v.id("messages") },
handler: async (ctx, args) => {
// Save a user's "like" of a particular message
await ctx.db.insert("likes", {
liker: args.liker,
messageId: args.messageId,
});
},
});

It lives! Er, likes! Now you just need a way for users to trigger the function. (Although you can test-run it yourself from the Dashboard if you'd like!)

Trigger the new function from the UI

A backend function for liking messages isn't much use if there's no way to trigger it from the UI! Let's fix that.

Exercise 2.4

Hook into the new mutation

In the <App> component, call the useMutation hook on the like mutation, naming the resulting function likeMessage or whatever you like (pun intended!)

Solution
src/App.tsx
const likeMessage = useMutation(api.messages.like);
Exercise 2.5

Button it up

Next, add a button element to the p that contains the message body. The text of the button is up to you, but perhaps a heart (🤍) is appropriate.

To make the button work, add an onClick handler that calls the likeMessage function, passing in the required arguments to indicate the current user and the message id (don't forget that mutations are async functions, so you'll need to await the results!):

Solution
src/App.tsx
<p>
{message.body}
<button
onClick={async () => {
await likeMessage({ liker: NAME, messageId: message._id });
}}
>
🤍
</button>
</p>

Huzzah! You can now confirm in the Dashboard that the likes table gets a new document every time a user clicks one of the heart buttons in the app.

Find out who liked what

While you can check your Convex Dashboard to see which messages were liked by someone, your users don't have that option! So to round out this feature you'll need to give them some way to see how many times a message has been liked.

That means that given a message ID, you'll need to find out how many documents in the likes table relate to that particular message. To do that, you could look at every single document in the likes table and check if it has the matching messageId, but that could get slow as your tables grow. Let's see if there's a more performant way.

Index likes by message ID

To quickly find all the likes for a given message, we can add an Index on the likes table that organizes all the likes documents by their messageId.

Exercise 2.6

Add an .index() to the likes table schema

In convex/schema.js, add an index to the likes table using the defineTable(...).index() method, which takes two arguments:

  1. a string name for this index, in this case "byMessageId"
  2. an array of the field(s) the index is based on, in this case: ["messageId"]
Solution
convex/schema.ts
export default defineSchema({
messages: defineTable({
author: v.string(),
body: v.string(),
}),
likes: defineTable({
liker: v.string(),
messageId: v.id("messages"),
}).index("byMessageId", ["messageId"]),
});

Now that the byMessageId index is set up, within a query function we can quickly find likes documents with a given messageId using the .withIndex() method, like so:

const likes = await ctx.db
.query("likes")
.withIndex("byMessageId", (q) => q.eq("messageId", message._id))
.collect();
Exercise 2.7

Use the new index to query likes data

Edit the list query in convex/messages.ts to also retrieve the likes for each message.

Before returning the messages array, .map over it with a likes query as shown above, passing in each message's id to find its corresponding likes (don't forget, this is an async operation so you'll need a Promise.all()).

Once you've got the .collect()ed likes array, tally up the total count (the array's .length). Include this extra info by joining the likes data with the original message data before returning from the .map(). You can use a spread operator to copy the messages document data into a new object.

Solution
convex/messages.tsx
export const list = query({
args: {},
handler: async (ctx) => {
// Grab the most recent messages.
const messages = await ctx.db.query("messages").order("desc").take(100);
const messagesWithLikes = await Promise.all(
messages.map(async (message) => {
// Find the likes for each message
const likes = await ctx.db
.query("likes")
.withIndex("byMessageId", (q) => q.eq("messageId", message._id))
.collect();
// Join the count of likes with the message data
return {
...message,
likes: likes.length,
};
}),
);
// Reverse the list so that it's in a chronological order.
return messagesWithLikes.reverse().map((message) => ({
...message,
// Format smileys
body: message.body.replaceAll(":)", "😊"),
}));
},
});

Fabulous, now you're cooking with gas! If you'd like, you can test-run the list function in the Dashboard to confirm that the returned messages now each have a likes property.

Do I *have* to create an index to find just the documents I need?

We recommend using indexes for better performance, but if that's not a concern you can also use a .filter() to find the documents you're looking for, e.g. in this case to compare the messageId values given a certain messages document:

const likes = await ctx.db
.query("likes")
.filter((q) => q.eq(q.field("messageId"), message._id))
.collect();

That q is a FilterBuilder, a special object Convex queries can use to specify certain conditions documents must meet to be returned. You can read more about it here: Reading Data: Filtering

Display the new data

Back in App.tsx, you can now use message.likes to display the count of likes for each message.

Exercise 2.8

Show off those likes

In App.tsx, add a <span> within the like button that displays the message.likes value, but only if it's non-zero.

Solution
src/App.tsx
<button
onClick={async () => {
await likeMessage({ liker: NAME, messageId: message._id });
}}
>
{message.likes ? <span>{message.likes}</span> : null} 🤍
</button>

Celebrate your win

You did it! You implemented an entire new feature in just a few minutes by defining a new table, writing a new mutation function, and joining data from two tables in a query function. Congratulations, you're now a Convex developer!

Why not get some friends together to chat about the awesome work you've done?

Recap

  • Convex client libraries for e.g. React let you "talk" to the Reactor from your app's frontend.
  • Your app's Convex backend is defined by modules in the convex/ directory within your codebase, including schema.ts which defines your schema, and other .ts files which define your Convex functions.
  • Schemas and functions are typed, so that TypeScript can give you helpful hints (and complaints!) to make sure your data is correct.
  • The Convex React client provides a useQuery hook that provides reactively-updating query results, and a useMutation hook that lets you run mutation functions as needed based on user behavior.
  • Query functions can look for data in multiple tables, and leverage table indexes to improve query performance.

Enough about functions: I want my AI!

OK OK we get it, an app without AI is as useless as a fish without a bicycle. Let's add a ChatGPT agent to the chat app, so that chatters can use AI on the fly! We'll escape out of the deterministic safety net of queries and mutations to learn how to interact with the world outside of Convex.