Skip to main content

Filters and References

Most applications require more expressive power than just a simple scan through all the messages in single table. Fortunately Convex supports a fully relational document model and powerful database queries. Let's extend our chat app with chat channels to use these features.

This page builds on the code in the previous section which you can fetch from the 1-convex-chat directory in the Convex Demos repo.

Relational Model

We want to support multiple chat channels, each with a set of messages. Typically this would be modeled as a many-to-one relationship between messages and a channel. We can add channels to our data model with a schema like:

convex/schema.ts
import { defineSchema, defineTable, s } from "convex/schema";

export default defineSchema({
channels: defineTable({
name: s.string(),
}),
messages: defineTable({
author: s.string(),
body: s.string(),
channel: s.id("channels"),
}),
});

The channels table only contains a name field with a string value.

The messages table looks very similar to our previous example but now each message contains a reference to the channel that contains the message.

In Convex, references are defined using Ids. Each document in Convex has an automatically generated _id: Id field which is the unique primary key for that document. The db.get(id) method can be used to fetch the document with a given id.

Here we've embedded the channel's Id into our message documents with channel: s.id("channels") to create the many-to-one relationship.

Like Document<TableName>, Convex automatically generates a Id<TableName> type in convex/_generated/dataModel.ts. This enables us to write type-safe TypeScript code that distinguishes between Ids from different tables.

caution

Id is a class. To check if two Ids refer to the same document, use myId.equals(otherId).

Importantly, === won't work because two different instances of this class can refer to the same document.

tip

You can see the full list of supported types at Types.

Creating channels and messages

The function to create a new channel inserts a new channel document in the database and returns the channel's Id. Let's add this function to our project:

convex/addChannel.ts
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";

// Create a new chat channel.
export default mutation(({ db }, name: string): Id<"channels"> => {
return db.insert("channels", { name });
});

Whenever we want to post a new message to the channel, we save the channel Id:

convex/sendMessage.ts
import { Id } from "./_generated/dataModel";
import { mutation } from "./_generated/server";

// Send a message to the given chat channel.
export default mutation(
({ db }, channel: Id<"channels">, body: string, author: string) => {
const message = {
channel,
body,
author,
};
db.insert("messages", message);
}
);

Filters

Now that we have multiple channels, let's update listMessages to query for messages that belong to a specific channel:

convex/listMessages.ts
import { Document, Id } from "./_generated/dataModel";
import { query } from "./_generated/server";

// List all chat messages in the given channel.
export default query(
async ({ db }, channel: Id<"channels">): Promise<Document<"messages">[]> => {
return await db
.table("messages")
.filter(q => q.eq(q.field("channel"), channel))
.collect();
}
);

The .filter() method can be used on a table to select for documents that match a specific predicate. In this case we're filtering for all messages where the channel field is a reference to the given channel. You can learn more about filters in the Convex database query documentation.

We need one more Convex function which just lists all the channels:

convex/listChannels.ts
import { Document } from "./_generated/dataModel";
import { query } from "./_generated/server";

// List all chat channels.
export default query(async ({ db }): Promise<Document<"channels">[]> => {
return await db.table("channels").collect();
});

We'll want to use these Convex functions in our app, so update your typed Convex client.

npx convex codegen

Application code

You might want to try your hand at turning these functions into a working chat application that supports multiple channels. The application code will likely look something like the following:

src/App.tsx
import { useState, FormEvent } from "react";
import { Document, Id } from "../convex/_generated/dataModel";
import { useMutation, useQuery } from "../convex/_generated/react";

const randomName = "User " + Math.floor(Math.random() * 10000);

// Render a chat message.
function MessageView(props: { message: Document<"messages"> }) {
const message = props.message;
return (
<div>
<strong>{message.author}:</strong> {message.body}
</div>
);
}

function ChatBox(props: { channelId: Id<"channels"> }) {
// Dynamically update `messages` in response to the output of
// `listMessages.ts`.
const messages = useQuery("listMessages", props.channelId) || [];
const sendMessage = useMutation("sendMessage");

// Run `sendMessage.ts` as a mutation to record a chat message when
// `handleSendMessage` triggered.
const [newMessageText, setNewMessageText] = useState("");
async function handleSendMessage(event: FormEvent) {
event.preventDefault();
setNewMessageText(""); // reset text entry box
await sendMessage(props.channelId, newMessageText, randomName);
}

return (
<div className="chat-box">
<ul className="list-group shadow-sm my-3">
{messages.slice(-10).map((message: any) => (
<li
key={message._id}
className="list-group-item d-flex justify-content-between"
>
<MessageView message={message} />
<div className="ml-auto text-secondary text-nowrap">
{new Date(message._creationTime).toLocaleTimeString()}
</div>
</li>
))}
</ul>
<form
onSubmit={handleSendMessage}
className="d-flex justify-content-center"
>
<input
value={newMessageText}
onChange={event => setNewMessageText(event.target.value)}
className="form-control w-50"
placeholder="Write a message…"
/>
<input
type="submit"
value="Send"
disabled={!newMessageText}
className="ms-2 btn btn-primary"
/>
</form>
</div>
);
}

export default function App() {
// Dynamically update `channels` in response to the output of
// `listChannels.ts`.
const channels = useQuery("listChannels") || [];

// Records the Convex document ID for the currently selected channel.
const [channelId, setChannelId] = useState<Id<"channels">>();

// Run `addChannel.ts` as a mutation to create a new channel when
// `handleAddChannel` is triggered.
const [newChannelName, setNewChannelName] = useState("");

const addChannel = useMutation("addChannel");

async function handleAddChannel(event: FormEvent) {
event.preventDefault();
setNewChannelName("");
const id = await addChannel(newChannelName);
setChannelId(id);
}

return (
<main className="py-4">
<h1 className="text-center">Convex Chat</h1>
<p className="text-center">
<span className="badge bg-dark">{randomName}</span>
</p>

<div className="main-content">
<div className="channel-box">
<div className="list-group shadow-sm my-3">
{channels.map(channel => (
<a
key={channel._id.toString()}
className="list-group-item channel-item d-flex justify-content-between"
style={{
display: "block",
fontWeight: channel._id.equals(channelId) ? "bold" : "normal",
}}
onClick={() => setChannelId(channel._id)}
>
{channel.name}
</a>
))}
</div>
<form
onSubmit={handleAddChannel}
className="d-flex justify-content-center"
>
<input
value={newChannelName}
onChange={event => setNewChannelName(event.target.value)}
className="form-control w-50"
placeholder="Add a channel..."
/>
<input
type="submit"
value="Add"
className="ms-2 btn btn-primary"
disabled={!newChannelName}
/>
</form>
</div>
{channelId ? <ChatBox channelId={channelId} /> : null}
</div>
</main>
);
}

Try it out

Push your Convex functions to the cloud with npx convex push, run the UI locally with npm run dev, then navigate your browser to localhost:3000 to try it out:

Multi-Channel Chat

info

Learn more about database queries and data modeling.