Skip to content

Conversation

@alifarooq9
Copy link

This PR enables React applications to connect to multiple Convex backends simultaneously by allowing an optional client parameter on all React hooks.

The Problem

See Issue #113 - React hooks implicitly use the nearest ConvexProvider context, making multi-backend architectures impossible.

The Solution

Updated the following React hooks to accept an optional client?: ConvexReactClient parameter:

Hook New Signature
useQuery useQuery(query, args?, client?)
useMutation useMutation(mutation, client?)
useAction useAction(action, client?)
useQueries useQueries(queries, client?)
usePaginatedQuery usePaginatedQuery(query, args, options, client?)
useConvexConnectionState useConvexConnectionState(client?)

How It Works

  1. If client is provided, the hook uses that specific client instance
  2. If client is undefined, the hook falls back to useConvex() (the context provider)
  3. If neither is available, an error is thrown (existing behavior)

Usage Example (After)

// neutral-convex.tsx - Custom provider for the secondary client
import type { ConvexReactClient } from "convex/react";
import { useContext, createContext, type ReactNode } from "react";

const NeutralConvexContext = createContext<ConvexReactClient | null>(null);

export function NeutralConvexProvider({ client, children }: { client: ConvexReactClient; children: ReactNode }) {
  return <NeutralConvexContext.Provider value={client}>{children}</NeutralConvexContext.Provider>;
}

export function useNeutralConvex(): ConvexReactClient {
  const context = useContext(NeutralConvexContext);
  if (!context) throw new Error("Wrap your app in <NeutralConvexProvider>");
  return context;
}
// providers.tsx - Both providers wrapping the app
const convex = new ConvexReactClient(env.NEXT_PUBLIC_CONVEX_URL);
const neutralConvex = new ConvexReactClient(env.NEXT_PUBLIC_NEUTRAL_CONVEX_URL);

export default function Providers({ children }: { children: ReactNode }) {
  return (
    <NeutralConvexProvider client={neutralConvex}>
      <ConvexProvider client={convex}>{children}</ConvexProvider>
    </NeutralConvexProvider>
  );
}
// page.tsx - Using both backends
import { api } from "@my-app/backend/convex/_generated/api";
import { api as neutralApi } from "@my-app/neutral-backend/convex/_generated/api";
import { useMutation, useQuery } from "convex/react";
import { useNeutralConvex } from "@/components/neutral-convex";

export default function Home() {
  const neutralClient = useNeutralConvex();

  // Main backend (uses context provider) - unchanged behavior
  const healthCheck = useQuery(api.healthCheck.get);
  const increaseValue = useMutation(api.healthCheck.increaseTheValue);

  // Neutral backend (explicit client) - NEW!
  const neutralHealthCheck = useQuery(neutralApi.healthCheck.get, {}, neutralClient);
  const increaseNeutralValue = useMutation(neutralApi.healthCheck.increaseTheValue, neutralClient);

  return (
    <div>
      <div>Main: {healthCheck}</div>
      <div>Neutral: {neutralHealthCheck}</div>
      <button onClick={() => increaseValue()}>Increase Main</button>
      <button onClick={() => increaseNeutralValue()}>Increase Neutral</button>
    </div>
  );
}

Changes

src/react/client.ts

  • useQuery: Added optional client parameter, passes to useQueries
  • useMutation: Added optional client parameter with fallback to context
  • useAction: Added optional client parameter with fallback to context
  • useConvexConnectionState: Added optional client parameter

src/react/use_queries.ts

  • useQueries: Added optional client parameter, uses client ?? useConvex()

src/react/use_paginated_query.ts

  • usePaginatedQuery: Added optional client parameter
  • usePaginatedQueryInternal: Added optional client parameter, passes to useQueries

src/react/use_paginated_query2.ts

  • usePaginatedQuery_experimental: Added optional client parameter

Backward Compatibility

This change is fully backward compatible:

  • All new parameters are optional
  • Existing code continues to work without modification
  • Default behavior (using context provider) is unchanged

Testing

  • Existing tests pass
  • Verified multi-client scenario works correctly
  • Tested backwards compatibility

Files Modified

  1. src/react/client.ts - useQuery, useMutation, useAction, useConvexConnectionState
  2. src/react/use_queries.ts - useQueries
  3. src/react/use_paginated_query.ts - usePaginatedQuery, usePaginatedQueryInternal
  4. src/react/use_paginated_query2.ts - usePaginatedQuery_experimental

By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.

@giceratops
Copy link

I opened #112 a few hours before this with. I've been playing a bit with the idea and came across some caveats.

A minor detail, changing the args on useQuery could potentially be breaking. Altough the arguments make no sense now so this is probably for the better.

As is, the FuncionReference is not aware of the client context. So you could pass Neutral api functions as app api functions ance vice versa. Without getting any warnings at compile time:

const neutralClient = useNeutralConvex();

// Both are valid TS but will fail at runtime
useQuery(api.custom.get, {}, neutralClient); 
useQuery(api.neutral.get, {}); // in app context

I'm currently entertaining @convex-dev/react-query with @tanstack/react-query and exporting custom useQuery/useMutation/useActions. Like so

import { convexQuery } from "@convex-dev/react-query";
import { useQuery, useQueryClient, UseQueryOptions, UseQueryResult } from "@tanstack/react-query";
import { OptionalRestArgsOrSkip } from "convex/react";
import { FilterApi, FunctionReference } from "convex/server";
import { api } from "../../convex/_generated/api";

type DotPaths<T> = {
  [K in keyof T & string]:
  T[K] extends FunctionReference<"query" | "mutation" | "action">
  ? K
  : T[K] extends object
  ? `${K}.${DotPaths<T[K]>}`
  : never
}[keyof T & string];


type ValueAtPath<T, P extends string> =
  P extends `${infer K}.${infer Rest}`
  ? K extends keyof T
  ? ValueAtPath<T[K], Rest>
  : never
  : P extends keyof T
  ? T[P]
  : never;

function getByDotPath<T, P extends DotPaths<T>>(
  obj: T,
  path: P,
): ValueAtPath<T, P> {
  return path.split(".").reduce(
    (acc, key) => acc?.[key],
    // eslint-disable-next-line @typescript-eslint/no-explicit-any
    obj as any,
  );
}

export type PublicQuery = FunctionReference<"query", "public">;

export type PublicQueryApi = FilterApi<typeof api, PublicQuery>;

export function useNeutralQueryByPath<
  Path extends DotPaths<PublicQueryApi>,
  Query extends ValueAtPath<PublicQueryApi, Path>
>(
  queryPath: Path,
  args: OptionalRestArgsOrSkip<Query>[0],
  opts?: Omit<UseQueryOptions<Query["_returnType"], Error, Query["_returnType"]>, "queryKey" | "queryFn">
): UseQueryResult<Query["_returnType"]> {
  const query = getByDotPath(api, queryPath);
  return useNeuralQuery(query, args, opts);
}

function useNeuralQuery<Query extends PublicQuery>(
  query: Query,
  args: OptionalRestArgsOrSkip<Query>[0],
  opts?: Omit<UseQueryOptions<Query["_returnType"], Error, Query["_returnType"]>, "queryKey" | "queryFn">
): UseQueryResult<Query["_returnType"]> {
  const queryClient = useNeutralQueryClient(); 
 
  return useQuery({
    ...convexQuery(query, args),
    ...opts,
  }, queryClient);
}

// get the query client from the neutral context
function useNeutralQueryClient() {
  return useQueryClient();
}

Which ends up like with typesafe functions.

useNeutralQueryByPath("custom.get", {}); // ❌ 
useNeutralQueryByPath("neutral.get", {}); // ✅
useQuery(api.neutral.get, {}); // ❌
useQuery(api.custom.get, {}); // ✅

@ianmacartney
Copy link
Contributor

The second arg is spread so that if there are no args, you don't have to pass {}, e.g. useQuery(api.health.check) instead of api.heath.check, {})

@ianmacartney
Copy link
Contributor

Another avenue here would be to have hooks be able to be generated via a factory:

export const { useQuery, usePaginatedQuery, ... } = convexClientHooks(neutralClient);

The default ones can infer from context. I can't recall if this is not supported by React hooks (I know they discourage dynamically created hooks, but maybe import-time generated ones like this are ok)

@ianmacartney
Copy link
Contributor

if we add another parameter, it would likely be a catch-all options object.
The most likely way to get this in would be to add it to the upcoming useQuery object syntax:

const { status, value } = useQuery({ query: api.foo.bar, args: {..}, client: neutralClient });

@alifarooq9
Copy link
Author

Another avenue here would be to have hooks be able to be generated via a factory:

export const { useQuery, usePaginatedQuery, ... } = convexClientHooks(neutralClient);

The default ones can infer from context. I can't recall if this is not supported by React hooks (I know they discourage dynamically created hooks, but maybe import-time generated ones like this are ok)

What if I am using agent component in both of the backends, I can use useUIMessage and other hooks for the default convex backend but I can't use it for neutral backend, the proposed solution allows so useUIMessage and other hooks for components can also take client as argument and pass it to convex hooks

if we add another parameter, it would likely be a catch-all options object. The most likely way to get this in would be to add it to the upcoming useQuery object syntax:

const { status, value } = useQuery({ query: api.foo.bar, args: {..}, client: neutralClient });

That implementation would be great if a default convex hooks can accept client

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants