Skip to main content

How it Works

Once you've got a Convex app up and running locally we can highlight the first few lines of code that will help you use Convex in your own project. Follow along with the video or read on below.

Hooking up React components

Here's the first half of the code for the App React component of the project you have running locally. React components communicate with the Convex backend via the hooks useQuery and useMutation. This component rerenders every time any user sends a new chat message.

src/App.jsx (excerpt)
import { useMutation, useQuery } from "../convex/_generated/react";

export default function App() {
const messages = useQuery("listMessages") || [];

const [newMessageText, setNewMessageText] = useState("");
const sendMessage = useMutation("sendMessage");

const [name] = useState(() => "User " + Math.floor(Math.random() * 10000));
async function handleSendMessage(event) {
event.preventDefault();
setNewMessageText("");
await sendMessage(newMessageText, name);
}

In the three highlighted lines above:

  • useQuery("listMessages") returns either the result of running the listMessages query function on the server or undefined if the query result is still loading. This hook causes the component to rerender whenever the query result changes.
  • useMutation("sendMessage") returns a function for telling the server to run the sendMessages mutation.
  • sendMessage(newMessageText, name) is called from an event handler, sending its arguments to the server so the sendMessages mutation function can be run with them.

Query functions

"listMessages" above refers to a query function: a JavaScript (or TypeScript) function that runs on Convex servers. The first argument passed to useQuery specifies the name of the file in the convex/ folder where this query function is the default export. This query function returns every document in the messages table.

import { query } from "./_generated/server";

export default query(async ({ db }) => {
return await db.query("messages").collect();
});
Passing arguments to queries

As their first argument, query functions receive an object with a db property that can be used to read from and write to Convex tables. You can pass your own arguments to query functions by adding them to the useQuery call.

// src/App.jsx
useQuery("listMessages", 10, false)
│ │ └───────────────────┐
│ └─────────────┐ │
// convex/listMessages.js │ │
export default query(async ({ db }, maxMessages, repeat) => {
// gets the latest maxMessages, ordered by most recent instead of earliest
const messages = await db.query("messages").order("desc").take(maxMessages);
// sort the messages back into chronological order
messages.reverse();
if (repeat) {
return messages.map(m => {
return {...m, body: `${m.body} ${m.body}`};
};
}
return messages;
});
Breaking down the JavaScript syntax used here
In case the syntax of the query function in `convex/listMessages.js` is unfamiliar let's break it down. Here's a simpler (and also totally valid) way to write the same query function.
const myFunction = async context => {
const db = context.db;
// Creates an array of all documents of the "messages" table
// similar to `SELECT * FROM messages`
const messages = await db.query("messages").collect();
return messages;
};
const myQuery = query(myFunction);
export default myQuery;

We can use parameter destructuring to pull out db since that's the only property of the query context object used in this function, and combine

const myFunction = async ({ db }) => {
const messages = await db.query("messages").collect();
return messages;
};
const myQuery = query(myFunction);
export default myQuery;

Besides being one line shorter, calling mutation() directly on the function we wrote helps code editors infer the types of its parameters.

const myMutation = mutation(async ({ db }, body, author) => {
const message = { body, author };
await db.insert("messages", message);
});
export default myMutation;

Inlining the export default is just a preference. We could also combine the middle two lines.

There's something special about query functions: they run automatically! Every time data that would be read by a query function changes, the function runs again and notifies all subscribed clients of the change, causing the relevant React components to rerender with the new query result.

Query functions are for more than returning collections of documents: in addition to a querying the Convex database you can run any JavaScript you need.

How would you change this query function to...

Return chat messages without revealing their authors?
Query functions don't need to return documents from tables: they can return modified documents or completely new objects. To avoid needing to change the frontend code we can modify the `author` property in returned messages instead of removing it.
export default query(async ({ db }) => {
const allMessages = await db.query("messages").collect();
return allMessages.map(m => {
return {
...m,
author: "anonymous",
};
});
});

How would you write a query function in a new JavaScript file in the convex/ folder...

called getMessagesByAuthor.js that returns only messages by a specific author?

Since query functions are just JavaScript, array methods like filter are often the simplest way to transform documents before returning them. This is a great way to get started.

export default query(async ({ db }, author) => {
const allMessages = await db.query("messages").collect();
return allMessages.filter(m => m.author === author);
});

You can use this new query in a React component with

const ownMessages = useQuery("getMessagesByAuthor", name);

But as tables get larger, it's more efficient to use the query builder to filter for the results we want instead processing an array containing the entire table of documents in JavaScript.

export default query(async ({ db }, author) => {
return await db
.table("messages")
.filter(q => q.eq(q.field("author"), author))
.collect();
});

Using an index would be more efficient still.

called getAuthors.js that returns a list of authors and how many messages they have posted?
// convex/.js
export default query(async ({ db }, maxMessages) => {
const allMessages = await db.query("messages").collect();
const authors = {};
for (const message of allMessages) {
authors[message.author] = (authors[message.authors] || 0) + 1;
}
return authors;
});

If you're thinking "hmmm, it sounds like there should be two tables here, one for messages and one for authors, with a foreign key in messages to specify the author," you're not wrong. Convex works best as a relational database!

See querying the database to learn about filters, references to documents in other tables (AKA foreign keys), ordering results, and indexes.

Mutation functions

Mutation functions are also normal JavaScript or TypeScript functions that run on Convex servers when requested. Clicking the submit button in our app tells the Convex backend to run the sendMessage mutation function with the arguments provided.

import { mutation } from "./_generated/server";

export default mutation(async ({ db }, body, author) => {
const message = { body, author };
await db.insert("messages", message);
});

This mutation function creates an object with two properties, body and author, and inserts that object into the messages table. Here's what your dashboard might look like after sending a few messages from two browser windows. You can see that documents in Convex tables get two other properties for free: _id and _creationTime.

Messages table in the Dashboard

Since mutations are just JavaScript, see if you can figure out how to make the following changes to the listMessages mutation function. If npx convex dev is still running, changes you make to convex/listMessages.js will be reflecting as soon as you save the file.

How would you change this mutation function...

to make every submitted chat messages ALL CAPS?

body is a string, so we can call .toUpperCase() on it to get the shouty version.

export default mutation(async ({ db }, body, author) => {
const message = { body: body.toUpperCase(), author };
await db.insert("messages", message);
});
to require messages to be longer than 10 characters?

Use an if statement! Remember, mutation functions are just JavaScript.

export default mutation(async ({ db }, body, author) => {
if (body.length < 10) {
return "Message is not long enough!";
}
await db.insert("messages", { body: body.toUpperCase(), author });
return "ok";
});

Mutation functions can also return values back to the UI, so let's modify the React component to do something with the result.

// src/App.jsx
const [errorMessage, setErrorMessage] = useState(null);
async function handleSendMessage(event) {
event.preventDefault();
setNewMessageText("");
const result = await sendMessage(newMessageText, name);
if (result === "ok") {
setError(undefined);
} else {
setError(result);
}
}
to prevent users from from spamming chat with too many messages? Hint: mutations can read data too!

We can check the creation time of the last message sent by a user and compare it with the current time. That might make you worry about race conditions: could two messages slip in together, both reading the same latest message? Don't worry, mutation functions run transactionally!

export default mutation(async ({ db }, body, author) => {
const now = Date.now();

const allMessages = await db.query("messages").collect();
const previousMessages = allMessages.filter(m => m.author === author);

const lastMessageSent = Math.max(previousMessages.map(m => m._creationTime));
if (lastMessageSent + 1000 * 10 > now) {
return "Too soon to send a new message";
}
await db.insert("messages", { body: body.toUpperCase(), author });
});

Being able to run arbitrary JavaScript in mutations and query functions is powerful! But it's often more efficient to express filters like this through the query builder, or even to use indexes (not shown here) to avoid bringing every document in a table into memory.

export default mutation(async ({ db }, body, author) => {
const now = Date.now();

const previousMessages = await db
.table("messages")
.filter(q => q.eq(q.field("author"), author))
.collect();

const lastMessageSent = Math.max(previousMessages.map(m => m._creationTime));
if (lastMessageSent + 1000 * 10 > now) {
return "Too soon to send a new message";
}
await db.insert("messages", { body: body.toUpperCase(), author });
});

This is just the beginning of a rate limit: the most glaring issue with it is that users could send in a different user name to evade it! To prevent this you'd want to add user authentication.

The Convex client

Back in the UI code, useQuery() and useMutation() could only be used in the App component because a ConvexProvider above it in the React component tree makes a ConvexReactClient instance available.

import { StrictMode } from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import { ConvexProvider, ConvexReactClient } from "convex/react";

const address = import.meta.env.VITE_CONVEX_URL;

const convex = new ConvexReactClient(address);

ReactDOM.render(
<StrictMode>
<ConvexProvider client={convex}>
<App />
</ConvexProvider>
</StrictMode>,
document.getElementById("root")
);
  • The url for a Convex deployment is retrieved from an environment variable. This URL might point to a production deployment in prod and your own personal development deployment when developing locally.
  • The ConvexReactClient constructor uses this URL to create a websocket connection to this server.
  • A ConvexProvider component uses a React context to provide all descendant React elements with this connection to the server.

What next?

This concludes a whirlwind tour of a Convex application: how to write Convex functions and how to hook them up. If you haven't yet, do try modifying the code: while npx convex dev is running, modifying a query will update the results in your browser instantly. If you have any trouble or want to ask questions, we're here to help.