Mike Cann's avatar
Mike Cann
23 minutes ago

I tried Expo's new Exact with Convex

I love Expo. My vibe coding app for kids, Let's Build It, is built on it, so when I saw they had put out a new thing called Exact I wanted to take a bit of a closer look.

It's super early days (like pre-alpha, pre pre-alpha) but the idea is that you write your UI once in their own little typed language called Contract, and then it runs natively on your phone, your Mac and the web. So very much in the Expo vein, but it's supposed to be much faster and have a lot of things in it designed to make agentic development much better.

Expo's announcement of Exact on XExpo's announcement of Exact on X

So of course I had to give it a go with Convex, just to see whether it was going to work or not.

0:00 / 0:00

That's a little chat app my agent built, running in Chrome on the left and natively on my iPhone on the right (and natively on my Mac too, which you can see in the video). Every message goes through the same Convex deployment in the cloud, so you type on one and it lands on all the others. Let's take a look.

The chat app

There's nothing fancy on the Convex side, it's just a normal Convex backend. A messages table, a typing table so you get the "iPhone is typing…" line, and a few queries and mutations. If you pop open the dashboard you can see the messages coming in from each device:

The messages table in the Convex dashboard, with one message from each deviceThe messages table in the Convex dashboard, with one message from each device

And because it's Convex, it's all nice and real time. You can tap the heart on a message and the count pops up everywhere at once:

0:00 / 0:00

The heart is just a mutation that reads the message and bumps its count in one transaction, so if two people tap at the same time both taps count:

// backend/convex/messages.ts
export const heart = mutation({
  args: { id: v.id("messages") },
  handler: async (ctx, { id }) => {
    const message = await ctx.db.get(id);
    if (!message) return;
    await ctx.db.patch(id, { hearts: (message.hearts ?? 0) + 1 });
  },
});

Simple enough, right? You'd write exactly the same thing for a React app.

So what's Contract like?

Contract is the language you write the UI in. Charlie Cheever, Expo's CEO, describes it like this in his announcement thread:

Charlie Cheever on X: Contract looks like Python, with indentation that has meaning, and it is explicit and strongly typedCharlie Cheever on X: Contract looks like Python, with indentation that has meaning, and it is explicit and strongly typed

And ye, it does look a lot like Python, but it's supposed to work much more like React, without a lot of the React overheads. It's got its own version of CSS baked in, and these shape things, which are effectively types. Here's a bit of the chat screen, the heart button on each message:

shape Message
  id: string
  author: string
  body: string
  hearts: number

component Chat
  mutation hearted as shape Reply queue
  action heart(id: string)
    haptic("impact-light")
    send hearted = addHeart(id)
  view
    ...
      button press=heart(m.id) appearance="none" aria-label="Heart"
        row gap=3 align-items="center"
          image (m.hearts > 0 ? "symbol:heart-fill" : "symbol:heart")

It looks kinda interesting, but to be totally honest, who codes anymore? As long as the agent understands it then I'm not sure it matters much what it looks like, and that's the whole point of Contract, it's designed for agents to read and write.

The thing that really got me interested though was this bit from Charlie's thread:

Charlie Cheever on X: in Exact your agent has control over the clock, so instead of sleeping and hoping an animation has finished, the agent advances the clock and then reads the resultCharlie Cheever on X: in Exact your agent has control over the clock, so instead of sleeping and hoping an animation has finished, the agent advances the clock and then reads the result

This sounds like one of those things that can trip up agents very easily. I don't know if you've had this, but sometimes the agent says it's done, and it has done a good job, but there's a glitch that only shows up when things are animating. The only way to show it is to record a video, and then the agent has to take slices of that video to work out where the glitch is coming from. If Exact can solve that problem, that would be awesome.

Changing the code live on every device

The other thing that's really cool is the dev loop. Colours live in a .tamber file, so I can change the accent colour in there:

tamber brand

theme
  AccentColor = #8d2676  scheme=fixed
  AccentColorText = #ffffff  scheme=fixed

And when I save, it updates on the web and on my phone (and the Mac) while the chat stays right where it was:

0:00 / 0:00

Just to be clear, this isn't changing a setting or something. Exact is building the code and pushing it out to all the devices, including my actual phone, not a simulator. It updates almost instantly, even on the phone. Super cool!

How the Convex bit works

Now the reason I said "just to see whether it was going to work" is that Exact can't run npm packages at runtime yet, so the normal convex client was out. Exact also has no global WebSocket, and it throws if you start a timer while you're handling a socket message, which the official client does.

So instead my agent wrote a small client (about 170 lines) that talks Convex's sync protocol directly, over the WebSocket that Exact gives you. When the socket opens it says hello and subscribes to the two queries the app needs:

// app/convex-sync.ts
connected(): string[] {
  const connect = {
    type: 'Connect',
    sessionId: this.sessionId,
    connectionCount: this.connectionCount++,
    lastCloseReason: this.lastCloseReason,
    // Exact bans Date.now() in data code. Convex only uses this to report
    // clock skew, so a fixed value is harmless here.
    clientTs: 0,
  };
  const subscribe = {
    type: 'ModifyQuerySet',
    baseVersion: 0,
    newVersion: 1,
    modifications: this.queries.map((q, queryId) => ({
      type: 'Add',
      queryId,
      udfPath: q.path,
      args: [q.args],
    })),
  };
  return [JSON.stringify(connect), JSON.stringify(subscribe)];
}

After that Convex just pushes down a new result every time messages:list or typing:list changes, and the Contract view re-renders off it. Mutations like messages:heart go back up the same socket.

The chat on the web and my iPhone, both reading the same live queriesThe chat on the web and my iPhone, both reading the same live queries

By the way, that sync protocol isn't a documented public API, so this is very much me (well, my agent) poking at it to see what happens, not official Convex support for Exact. The client pins the protocol to a Convex version it was tested against, and the full app, including the Convex functions and how to run it on your own phone, is up on GitHub at mikecann/exact-convex-chat if you want to have a go yourself.

Conclusion

So just to recap: Expo's Exact lets you write your UI once in Contract and run it natively on your phone, your Mac and the web, and with a little hand-rolled client Convex works with it today, with every screen subscribed to the same live queries.

It's super early days for Exact, so I'm sure lots of this will change, but I love that it already plays really nicely with Convex. Because everything in Convex is just queries and mutations over one socket, a brand new framework only needs that socket to get live data on every device, and that's pretty cool I think.

Until next time, cheerio! 👋

All gas, no breakages

Convex is the reactive backend platform that keeps up with you and your agents. Database, functions, workflow, sync, search, file storage, and more. All TypeScript, zero glue.

Get started