Stack logo
Sync up on the latest from Convex.
Mike Cann's avatar
Mike Cann
7 days ago

Why Convex Queries are the Ultimate Form of Derived State

Let's talk about state, specifically Derived State, and how I believe people are overlooking what I think is the end-game of derived state: Convex Queries.

State

First we should all know what state is but just so we are clear what I mean when I talk about state in this post lets look at this example:

1let name = "Mike";
2

Pretty obvious. Its just a variable that holds some data. Where this data is stored doesn't really matter, it could be held in memory, in a JS runtime, or it could be stored in your favorite database in the cloud.

We can easily manipulate this state:

1let name = "Mike";
2name = "Cann"
3

In so doing we have altered the original state.

I know this is obvious stuff but stick with me as its going to get interesting soon I promise.

Derived State

Now, what if we want to keep that original state around but also adapt it to make some more state? This is what Derived State is.

For example:

1const name = "Mike";
2const age = 21;
3
4const message = `${name} is ${age}`;
5

Here, message is derived state because it's calculated from name and age. It doesn't exist independently, it depends entirely on these two variables.

Simple enough, right?

But what if we want name and age to be changeable while keeping the derived state reflecting their relationship?

That's where functions come in:

1const getMessage = (name: string, age: number) => `${name} is ${age}`;
2
3console.log(getMessage("mike", 21)); // writes "mike is 21"
4

We actually do this sort of thing all the time in the React world using Function Components:

1export const Message: React.FC<{ name: string; age: number }> = ({ name, age }) => {
2  return (
3    <div>
4      {name} is {age}
5    </div>
6  );
7};
8

The elements returned from this component are derived from the input props.

Using React, we can contain state locally within the component. By leveraging the useState hook, we ensure the derived message stays up to date whenever the local state changes:

1import { useState } from "react";
2
3export const MessageLabel: React.FC = ({  }) => {
4  const [name, setName] = useState("mike");
5  const [age, setAge] = useState(21);
6
7  return (
8    <div>
9      <button onClick={() => setAge(age - 1)}>Reverse Aging</button>
10      {name} is {age}
11    </div>
12  );
13};
14

Server State

The problems start when we want more than just you to be able to see your lovely state. We need a way to share it between many clients, this generally means moving that state to a server for distribution:

Now the server holds the state (typically in a database) and when the client loads, it can request this state from the server:

1import { useEffect, useState } from "react";
2
3export const MessageLabel: React.FC = ({  }) => {
4  const [name, setName] = useState<string>();
5  const [age, setAge] = useState(0);
6
7  // Runs when the component is mounted lets load the initial state
8  useEffect(() => {
9	  // Just pretend this is defined on a server somewhere
10    fetch("https://myserver.com/getState")
11      .then(resp => resp.json())
12      .then(data => {
13        setName(data.name);
14        setAge(data.age);
15      })
16  }, []);
17
18  // Not loaded yet? Lets show a loading indicator
19  if (name === undefined) return <div>loading..</div>
20
21  return (
22    <div>
23      <button onClick={() => setAge(age - 1)}>Reverse Aging</button>
24      {name} is {age}
25    </div>
26  );
27};
28

There's an obvious issue here. When we click the button, the age only changes locally. As soon as we refresh the page, that age state resets to whatever is stored on the server.

We can fix this by moving the setState operation to the server and calling it from the button:

1export const MessageLabel: React.FC = ({  }) => {
2  ...
3  return (
4    <div>
5      <button
6        onClick={() =>
7          fetch("https://myserver.com/reverseAging", {
8            method: "POST",
9          }).then(() => window.location.reload())
10        }
11      >
12        Reverse Aging
13      </button>
14      ...
15    </div>
16  );
17};
18

After setting the state, we reload the page to view the updated data.

Not a great user experience but let's just go with it for now.

Looking at the component one more time, I think we could streamline things a bit more. The client only really cares about the message, not name and age separately. So we could have the server just send down the "derived" message instead:

1export const MessageLabel: React.FC = ({  }) => {
2  const [message, setMessage] = useState<string>();
3
4  useEffect(() => {
5    fetch("https://myserver.com/getMessage")
6      .then(resp => resp.text())
7      .then(setMessage)
8  }, []);
9
10  if (message === undefined) return <div>loading..</div>
11
12  return (
13    <div>
14      <button
15        onClick={() =>
16          fetch("https://myserver.com/reverseAging", {
17            method: "POST",
18          }).then(() => window.location.reload())
19        }
20      >
21        Reverse Aging
22      </button>
23      {message}
24    </div>
25  );
26};
27

Great, now our derived state comes from the server on request. But what happens when another client updates the state?

Now our view of the state differs between the two clients until one of these refreshes the page. This happens because we lack a mechanism to notify everyone when the state changes.

This creates a poor user experience and can lead to frustrating consequences. For example, if Client B reverses aging and then Client A does the same, Client A will see the value 19 when they expected 20.

I could show you how to solve these problems using WebSocket's, Server Sent Events or Server Side Rendering, but just trust me, you don't want to deal with these complexities unless absolutely necessary.

So instead, let's cut to the chase and see how Convex solves this and why I think it's so powerful.

State on Convex

The first thing we want to do is define the “schema” or the shape we want our state to take:

1// convex/schema.ts
2
3import { defineSchema, defineTable } from "convex/server";
4import { v } from "convex/values";
5
6export default defineSchema({
7  myState: defineTable({
8    name: v.string(),
9    age: v.number(),
10  }),
11});
12
13

Now we can write the server side function to access the data like so:

1// convex/myState.ts
2
3import { query } from "./_generated/server";
4
5export const getMessage = query(async (context) => {
6  const state = await context.db.query("myState").first();
7  if (!state) throw new Error("Could not find myState");
8
9  return `${state.name} is ${state.age}`;
10});
11

And a mutation to update the state like so:

1// convex/myState.ts
2
3import { mutation, query } from "./_generated/server";
4
5...
6
7export const reverseAging = mutation(async (context) => {
8  // Grab the current state first
9  const currentState = await context.db.query("myState").first();
10  if (!currentState) throw new Error("Could not find myState");
11
12  // Then update it
13  await context.db.patch(currentState._id, {
14    age: currentState.age + 1,
15  });
16});
17
We are going to assume that the initial value for the data has been previously entered elsewhere in these examples

Naive querying via useEffect

We can then update our client to use this new query:

1import { useConvex } from "convex/react";
2import { useEffect, useState } from "react";
3import { api } from "../convex/_generated/api";
4
5export const MessageLabel: React.FC = ({}) => {
6  const [message, setMessage] = useState<string>();
7
8  // Grab the client we can use to make a type-safe query to the server with
9  // the React provider for this exists higher up in the tree
10  const convex = useConvex();
11
12  // On mount
13  useEffect(() => {
14    // Get the message from the server and set it
15    // NOTE: this isnt the best way to use Convex, we'll upgrade this in a minute
16    convex.query(api.myState.getMessage).then(setMessage);
17  }, []);
18
19  if (message === undefined) return <div>loading..</div>;
20
21  return (
22    <div>
23      <button
24        onClick={() =>
25          convex
26            .mutation(api.myState.reverseAging)
27            .then(() => window.location.reload())
28        }
29      >
30        Reverse Aging
31      </button>
32      {message}
33    </div>
34  );
35};
36
37

This is cleaner and works the same as before but we aren't yet harnessing the true power of Convex, lets simplify things a bit more by using the useQuery and useMutation hooks:

Idiomatic querying via useQuery

1import { useMutation, useQuery } from "convex/react";
2import { api } from "../convex/_generated/api";
3
4export const MessageLabel: React.FC = ({}) => {
5  // We simply declare we want the message and let Convex worry about keeping it updated
6  const message = useQuery(api.myState.getMessage);
7
8  // A nice type-safe way of updating our state
9  const reverseAging = useMutation(api.myState.reverseAging);
10
11  if (message === undefined) return <div>loading..</div>;
12
13  return (
14    <div>
15      <button onClick={() => reverseAging()}>Reverse Aging</button>
16      {message}
17    </div>
18  );
19};
20

Now we're cooking!

This code is much cleaner. We simply declare that we want the message with useQuery(api.myState.getMessage) without any messy useEffect logic.

Notice we also removed the page reload when we changed the button click to use useMutation(api.mike.reverseAging);. What happens now when we click the button? This is where the power of the Convex sync engine shines.

When we call reverseAging, it updates the state in our database. Since Convex knows we've declared our interest in api.mike.getMessage, it automatically re-runs that query and syncs the result to the client.

Super cool! And we didn't need to modify our server-side code at all to enable this.

So our server-side query has effectively become the source of our "derived state"—and better yet, it automatically stays in sync whenever the underlying state changes.

Lists

To make this a bit more concrete lets take a look at a more realistic example from the docs. Starting with the following schema:

1// convex/shema.ts
2
3import { defineSchema, defineTable } from "convex/server";
4import { v } from "convex/values";
5
6export default defineSchema({
7
8  // Define a messages table with two indexes.
9  messages: defineTable({
10    channel: v.id("channels"),
11    body: v.string(),
12    user: v.id("users"),
13  })
14    .index("by_channel", ["channel"])
15    .index("by_channel_user", ["channel", "user"])
16    
17});
18

We can then write a query like so:

1// convex/chat.ts
2
3import { query } from "./_generated/server";
4import { v } from "convex/values";
5
6export const getMessagesInChannel = query({
7  args: { channel: v.id("channels") },
8  handler: async (ctx, args) => {
9    return await ctx.db
10		  .query("messages")
11		  .withIndex("by_channel", (q) =>
12		    q
13		      .eq("channel", channel)
14		      .gt("_creationTime", Date.now() - 2 * 60000)
15		      .lt("_creationTime", Date.now() - 60000),
16		  )
17		  .collect();
18  },
19});
20

When any message within the specified channel and time window is added, removed, or modified, the query automatically re-executes and synchronizes with all subscribed clients.

This is really powerful because our derived state can automatically depend not just on one document, but on many documents across a table or even multiple tables!

State in a Convex World

Because Convex queries are able to provide derived state on the server side which is also able to stay fresh when your state changes you can just start to think of the server state as your single “source of truth”.

It turns out you don't need local state management libraries like Redux or MobX to “cache” your server-side state. You can simply write a query that is highly targeted towards the use-case your client needs:

1export const getTheStateINeedToRenderMySidebarOnTheSettingsPage = query(async () => {
2  return {
3    someState: ...,
4    someOtherState: ...,
5  }
6});
7

This is sometimes known as Backend for Frontend and is one of the things that allows you as a developer to move so quickly when building on Convex.

Conclusion

I hope I've shown why Convex queries are powerful when viewed as derived state. You can subscribe to this derived state, just like with MobX or other reactive local state libraries, and it updates automatically whenever the underlying state changes.

Best of all, this synchronization works seamlessly across countless clients on different devices simultaneously!

I'm only scratching the surface of what's possible. You should definitely check out the comprehensive docs if you want to learn more.

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started