2: Convex and your app
You are here
Convex Tour
- The reactor
- Convex and your app
Learn how to connect your project to Convex and quickly build out new fullstack features
- The platform in action
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.
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 theConvexReactClient
context that connects the frontend to your Convex projectsrc/App.tsx
contains a single<App>
React component, which defines the entire UI for this simple projectsrc/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 tolist
all messages andsend
a new messageconvex/_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>
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:
useQuery
: React Client: Fetching Data.useMutation
: React Client: Editing Data
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.
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
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.
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 aliker
string and amessageId
id to match the shape of thelikes
table).handler
: an async function accepting two arguments, a context object you can callctx
and an arguments object matching the signature defined inargs
, from which you can destructure the individual arguments. Leave the body of the function empty for now.
Solution
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.
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
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.
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
const likeMessage = useMutation(api.messages.like);
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
<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
.
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:
- a string name for this index, in this case
"byMessageId"
- an array of the field(s) the index is based on, in this case:
["messageId"]
Solution
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();
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
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.
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
<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, includingschema.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 auseMutation
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 an AI agent to the chat app! To do so we'll leave the deterministic safety net of queries and mutations and learn how to use actions to interact with the world outside of Convex.