· 9 min read

Convex + React, Without the Hooks

Why I stopped using useQuery() in my Convex React app and moved to controllers and stores instead. The patterns that work at scale.

ReactConvexArchitectureMobX

Convex + React, Without the Hooks

I have been building an AI photo transformation app on Convex for a few months now. I love Convex. I think it’s the platform to build on right now, especially if you’re using LLMs to write code. Having your database schema, backend logic, and frontend UI all in one codebase, all in TypeScript, is a massive advantage. The Convex team gets a lot right.

But here’s the thing about quickstart documentation: it’s optimized for getting started, not for building production apps at scale. Sample code rarely represents what works best when you have fifty components and thirty tables. And when you’re using LLMs to write code, they reproduce whatever patterns the documentation shows. Following the Convex pattern, your components end up looking like this:

function ImageLibrary() {
  const images = useQuery(api.images.getMyImages);
  
  if (images === undefined) return <Spinner />;
  return <ImageGrid images={images} />;
}

Simple. Reactive. And that’s exactly what Claude Code generated throughout my codebase. But I don’t accept code from LLMs without reading it. I look at the architecture, I trace the data flow, and if they’re not building things the way I would, I push them toward my patterns. So when I saw useQuery() scattered across dozens of components, I knew it was going to be a problem.

Here’s the thing: the problems with this pattern don’t show up in a quickstart tutorial. They don’t show up when you have five components and two database tables. They show up when you have fifty components and thirty tables, when you’re trying to refactor a feature, when you’re debugging something that only breaks in production, when a new developer joins and asks “where does this data come from?” By then, you’re deep in the weeds and the cost of changing is high.

The components became untestable. Every time I wanted to move UI around, I had to figure out where the data fetching should live now. Debugging meant tracing through React’s render cycle instead of just reading code. The fix wasn’t abandoning Convex—I love Convex. The fix was abandoning useQuery() in my components. I pointed Claude Code at a different codebase I had written by hand years ago, one that uses plain TypeScript controllers instead of hooks. Once the LLM saw that pattern, it started generating code the way I wanted.

The Problem

Here’s the thing about useQuery() everywhere: it bundles three different concerns into one place. Data fetching, state management, and rendering all live in the same component. When you want to test just one of those things, you have to set up all three. When you want to refactor your UI, you’re also refactoring your data flow. When something breaks, you’re debugging React’s lifecycle instead of your actual logic.

This hit me hard because of how I debug. I have been building software systems for decades, and my usual approach involves walking through the code in my head, imagining the flow of information through the system. With hooks scattered across components, I couldn’t build that mental model anymore. The logic was everywhere and nowhere.

The Quickstart, Rewritten

Let me show you what this looks like with the simplest possible example. Here’s the task list from the Convex React quickstart:

import { useQuery } from "convex/react";
import { api } from "../convex/_generated/api";

function App() {
  const tasks = useQuery(api.tasks.get);
  return (
    <div className="App">
      {tasks?.map(({ _id, text }) => <div key={_id}>{text}</div>)}
    </div>
  );
}

Here’s the same thing with controllers. First, a simple store:

// stores/TaskStore.ts
import { makeAutoObservable } from 'mobx';

export class TaskStore {
  tasks: Array<{ _id: string; text: string }> = [];
  loading = false;

  constructor() {
    makeAutoObservable(this);
  }

  setTasks(tasks: Array<{ _id: string; text: string }>) {
    this.tasks = tasks;
  }

  setLoading(loading: boolean) {
    this.loading = loading;
  }
}

export const taskStore = new TaskStore();

Then the controller:

// controllers/TaskController.ts
import { ConvexClient } from 'convex/browser';
import { api } from '../convex/_generated/api';
import { TaskStore } from '../stores/TaskStore';

export class TaskController {
  constructor(
    private store: TaskStore,
    private convex: ConvexClient
  ) {}

  async load(): Promise<void> {
    this.store.setLoading(true);
    const tasks = await this.convex.query(api.tasks.get);
    this.store.setTasks(tasks);
    this.store.setLoading(false);
  }
}

And the component:

import { observer } from 'mobx-react-lite';
import { taskStore } from '../stores/TaskStore';

const App = observer(() => {
  const { tasks, loading } = taskStore;
  
  if (loading) return <div>Loading...</div>;
  
  return (
    <div className="App">
      {tasks.map(({ _id, text }) => <div key={_id}>{text}</div>)}
    </div>
  );
});

Yes, it’s more files. But look at what you get: the controller is testable without React, the store is just data, and the component is a pure view. When this grows to handle adding tasks, completing tasks, filtering, and pagination—the component stays simple. The complexity lives in the controller where you can actually reason about it.

A Real-World Example

That simple task list shows the pattern. But what does it look like in a real app with pagination, filtering, caching, and error handling? Here’s the controller that manages my image library:

import { ConvexClient } from 'convex/browser';
import { api } from '../convex/_generated/api';
import { ImageStore } from '../stores/ImageStore';

const PAGE_SIZE = 100;

export class LibraryController {
  constructor(
    private store: ImageStore,
    private convex: ConvexClient
  ) {}

  async load(): Promise<void> {
    if (this.store.loading) return;

    this.store.setLoading(true);
    this.store.setError(null);

    try {
      const result = await this.convex.query(api.r2ImagesInternal.getMyImages, {
        type: this.store.typeFilter ?? undefined,
        limit: PAGE_SIZE,
        offset: 0,
      });

      this.store.setImages(result.images, result.total);
    } catch (error) {
      this.store.setError('Failed to load images');
    } finally {
      this.store.setLoading(false);
    }
  }

  async loadMore(): Promise<void> {
    if (this.store.loading || !this.store.hasMore) return;

    this.store.setLoading(true);
    
    const result = await this.convex.query(api.r2ImagesInternal.getMyImages, {
      type: this.store.typeFilter ?? undefined,
      limit: PAGE_SIZE,
      offset: this.store.images.length,
    });

    this.store.appendImages(result.images, result.total);
    this.store.setLoading(false);
  }

  setTypeFilter(filter: 'upload' | 'generated' | null): void {
    this.store.setTypeFilter(filter);
    void this.load();
  }
}

Look at what this gives me. The ConvexClient is injected, so I can pass a mock in tests. All the pagination logic is in one place—tracking offsets, preventing duplicate loads, handling the “no more data” case. When something goes wrong, I look in this file. Not scattered across five components.

The component becomes almost nothing:

export const ImageLibrary = observer(() => {
  const { images: imageStore } = appController.store;
  const commandBus = getCommandBus();

  const handleLoadMore = () => {
    commandBus.execute(LibraryCommands.loadMore());
  };

  if (imageStore.error) return <ErrorMessage message={imageStore.error} />;

  return (
    <div className="grid">
      {imageStore.images.map(img => <ImageCard key={img.id} image={img} />)}
      {imageStore.loading && <Spinner />}
      {imageStore.hasMore && !imageStore.loading && (
        <button onClick={handleLoadMore}>Load More</button>
      )}
    </div>
  );
});

It reads from the store. It dispatches commands. It renders. I can move this component anywhere without touching the data logic.

Setting It Up

You need a ConvexClient instance that your controllers can use. I initialize everything in main.tsx:

// main.tsx
import { ConvexClient } from 'convex/browser';

const convexUrl = import.meta.env.VITE_CONVEX_URL as string;
const convexClient = new ConvexClient(convexUrl);
await initializeAuth(convexClient);
appController.initialize(convexClient);

The appController creates all the domain controllers and passes them the shared ConvexClient. You can still keep ConvexProvider at your app root if you need hooks somewhere—I use it for auth syncing. But the architecture doesn’t depend on it anymore.

Testing

This is where the pattern really pays off:

describe('LibraryController', () => {
  it('loads images and updates store', async () => {
    const store = new ImageStore();
    const mockConvex = {
      query: jest.fn().mockResolvedValue({
        images: [{ id: '1' }, { id: '2' }],
        total: 2,
      }),
    };
    const controller = new LibraryController(store, mockConvex as any);

    await controller.load();

    expect(store.images).toHaveLength(2);
    expect(store.loading).toBe(false);
  });

  it('prevents duplicate loads while loading', async () => {
    const store = new ImageStore();
    store.setLoading(true);
    const mockConvex = { query: jest.fn() };
    const controller = new LibraryController(store, mockConvex as any);

    await controller.load();

    expect(mockConvex.query).not.toHaveBeenCalled();
  });
});

No React testing library. No mounting components. No act() wrappers. Just instantiate the controller, call the method, check the result. Tests run in milliseconds.

What About Real-Time?

Convex’s real-time subscriptions still work—you just use them in controllers instead of components. Instead of useQuery() in your JSX, you call convex.onUpdate() in your controller and update the store when data changes:

this.convex.onUpdate(
  api.visionAnalysis.getImageAnalysis,
  { imageId },
  (result) => {
    if (result) {
      this.store.setVisionAnalysis(result.analysis, result.status);
    }
  }
);

The store is reactive (MobX in my case), so when onUpdate() fires and the controller updates the store, any observer() components re-render automatically. Same real-time behavior as useQuery(), but the subscription logic lives in the controller where you can reason about it, test it, and manage its lifecycle explicitly.

The Result

After moving to this pattern:

  • Tests run in under 100ms instead of seconds
  • I can experiment with UI layouts without touching data logic
  • When there’s a bug, I know which file to look in
  • I can actually walk through the code in my head again

Convex is a great backend. Its React hooks are designed for quick demos and simple apps, and they’re good at that. But if you’re building something that needs to survive real testing and real refactoring, consider skipping useQuery() in your components.

Put the logic in controllers. Let your components just render.

Try It Yourself

If you want to try this pattern in your own Convex React app, I built a Claude Code skill that does the transformation. It’s called “No Hooks React Convex.” Point it at your codebase and it will help you pull the logic and state out of your components into controllers and stores, leaving your React components as pure views. The skill knows the pattern I described here—ConvexClient in controllers, reactive stores for state, components that just subscribe and render. It’s the same approach Claude Code uses on my codebase now, packaged so you can use it on yours.