convex
Version:
Client for the Convex Cloud
568 lines (534 loc) • 17.4 kB
text/typescript
/**
* Tools to integrate Convex into React applications.
*
* This module contains:
* 1. {@link ConvexReactClient}, a client for using Convex in React.
* 2. {@link ConvexProvider}, a component that stores this client in React context.
* 2. [Hooks](https://docs.convex.dev/generated-api/react#react-hooks) for calling into
* this client within your React components.
*
* ## Usage
*
* ### Creating the Client
*
* ```typescript
* import { ConvexReactClient } from "convex/react";
* import clientConfig from "../convex/_generated/clientConfig";
*
* const convex = new ConvexReactClient(clientConfig);
* ```
*
* ### Storing the Client In React Context
*
* ```typescript
* import { ConvexProvider } from "convex/react";
*
* <ConvexProvider client={convex}>
* <App />
* </ConvexProvider>
* ```
*
* ### Generating the Hooks
*
* This module is typically used alongside generated hooks.
*
* To generate the hooks, run `npx convex codegen` in your Convex project. This
* will create a `convex/_generated/react.js` file with the following React
* hooks, typed for your queries and mutations:
* - [useQuery](https://docs.convex.dev/generated-api/react#usequery)
* - [useMutation](https://docs.convex.dev/generated-api/react#usemutation)
* - [useConvex](https://docs.convex.dev/generated-api/react#useconvex)
*
* If you aren't using code generation, you can use these untyped hooks instead:
* - {@link useQueryGeneric}
* - {@link useMutationGeneric}
* - {@link useConvexGeneric}
*
* ### Using the Hooks
*
* ```typescript
* import { useQuery, useMutation } from "../convex/_generated/react";
*
* function App() {
* const counter = useQuery("getCounter");
* const increment = useMutation("incrementCounter");
* // Your component here!
* }
* ```
* @module
*/
import {
GenericAPI,
InternalConvexClient,
MutationNames,
NamedMutation,
NamedQuery,
QueryNames,
} from "../browser/index.js";
import type { OptimisticUpdate, QueryToken } from "../browser/index.js";
import React, { useContext, useMemo } from "react";
import { convexToJson } from "@convex-dev/common";
import ReactDOM from "react-dom";
import { useSubscription } from "./use_subscription.js";
import { ClientConfiguration } from "../browser/client_config.js";
// TODO add runtime check that React version is good too.
if (typeof React === "undefined") {
throw new Error("Required dependency 'react' not installed");
}
if (typeof ReactDOM === "undefined") {
throw new Error("Required dependency 'react-dom' not installed");
}
// TODO Typedoc doesn't generate documentation for the comment below perhaps
// because it's a callable interface.
/**
* An interface to execute a Convex mutation function on the server.
*
* @public
*/
export interface ReactMutation<
API extends GenericAPI,
Name extends MutationNames<API>
> {
/**
* Execute the mutation on the server, returning a `Promise` of its return value.
*
* @param args - Arguments for the mutation to pass up to the server.
* @returns The return value of the server-side function call.
*/
(...args: Parameters<NamedMutation<API, Name>>): Promise<
ReturnType<NamedMutation<API, Name>>
>;
/**
* Define an optimistic update to apply as part of this mutation.
*
* This is a temporary update to the local query results to facilitate a
* fast, interactive UI. It enables query results to update before a mutation
* executed on the server.
*
* When the mutation is invoked, the optimistic update will be applied.
*
* Optimistic updates can also be used to temporarily remove queries from the
* client and create loading experiences until a mutation completes and the
* new query results are synced.
*
* The update will be automatically rolled back when the mutation is fully
* completed and queries have been updated.
*
* @param optimisticUpdate - The optimistic update to apply.
* @returns A new `ReactMutation` with the update configured.
*
* @public
*/
withOptimisticUpdate(
optimisticUpdate: OptimisticUpdate<
API,
Parameters<NamedMutation<API, Name>>
>
): ReactMutation<API, Name>;
}
function createMutation<
API extends GenericAPI,
Name extends MutationNames<API>
>(
name: Name,
sync: () => InternalConvexClient,
update: OptimisticUpdate<
API,
Parameters<NamedMutation<API, Name>>
> | null = null
): ReactMutation<API, Name> {
function mutation(
...args: Parameters<NamedMutation<API, Name>>
): Promise<ReturnType<NamedMutation<API, Name>>> {
return sync().mutate(name, args, update);
}
mutation.withOptimisticUpdate = function withOptimisticUpdate(
optimisticUpdate: OptimisticUpdate<
API,
Parameters<NamedMutation<API, Name>>
>
): ReactMutation<API, Name> {
if (update !== null) {
throw new Error(
`Already specified optimistic update for mutation ${name}`
);
}
return createMutation(name, sync, optimisticUpdate);
};
return mutation;
}
/**
* A watch on the output of a Convex query function.
*
* @public
*/
export interface Watch<F extends (...args: any[]) => any> {
/**
* Initiate a watch on the output of a query.
*
* This will subscribe to this query and call
* the callback whenever the query result changes.
*
* **Important: If the query is already known on the client this watch will
* never be invoked.** To get the current, local result call
* {@link react.Watch.localQueryResult}.
*
* @param callback - Function that is called whenever the query result changes.
* @returns - A function that disposes of the subscription.
*/
onUpdate(callback: () => void): () => void;
/**
* Get the current result of a query.
*
* This will only return a result if we're already subscribed to the query
* and have received a result from the server or the query value has been set
* optimistically.
*
* @returns The result of the query or `undefined` if it isn't known.
* @throws An error if the query encountered an error on the server.
*/
localQueryResult(): ReturnType<F> | undefined;
}
/**
* Options for {@link ConvexReactClient}.
*
* @public
*/
export type ReactClientOptions = {
/**
* Whether to prompt the user that have unsaved changes pending
* when navigating away or closing a web page with pending Convex mutations.
* This is only possible when the `window` object exists, i.e. in a browser.
* The default value is `true`.
*/
unsavedChangesWarning?: boolean;
/**
* Specifies an alternate [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket) constructor to use for client communication with the Convex cloud. The default behavior is to use `WebSocket` from the global environment.
*/
webSocketConstructor?: typeof WebSocket;
};
const DEFAULT_OPTIONS: ReactClientOptions = {
unsavedChangesWarning: true,
};
/**
* A Convex client for use within React.
*
* This loads reactive queries and executes mutations over a WebSocket.
*
* @typeParam API - The API of your application, composed of all Convex queries
* and mutations. `npx convex codegen` [generates this type](/generated-api/react#convexapi)
* in `convex/_generated/react.d.ts`.
* @public
*/
export class ConvexReactClient<API extends GenericAPI> {
private clientConfig: ClientConfiguration;
private cachedSync?: InternalConvexClient;
private listeners: Map<QueryToken, Set<() => void>>;
private options: ReactClientOptions;
private closed = false;
private adminAuth?: string;
/**
* @param clientConfig - The generated client configuration for your project.
* You can find this in `convex/_generated/clientConfig.js`.
* @param options - See {@link ReactClientOptions} for a full description.
*/
constructor(clientConfig: ClientConfiguration, options?: ReactClientOptions) {
this.clientConfig = clientConfig;
this.listeners = new Map();
this.options = { ...DEFAULT_OPTIONS, ...options };
}
/**
* Lazily instantiate the `InternalConvexClient` so we don't create the WebSocket
* when server-side rendering.
*/
private get sync() {
if (this.closed) {
throw new Error("ConvexReactClient has already been closed.");
}
if (this.cachedSync) {
return this.cachedSync;
}
this.cachedSync = new InternalConvexClient(
this.clientConfig,
updatedQueries => this.transition(updatedQueries),
this.options
);
if (this.adminAuth) {
this.cachedSync.setAdminAuth(this.adminAuth);
}
return this.cachedSync;
}
/**
* Set the authentication token to be used for subsequent queries and mutations.
* Should be called whenever the token changes (i.e. due to expiration and refresh)
* @param token - JWT-encoded OpenID Connect Identity Token
*/
setAuth(token: string) {
this.sync.setAuth(token);
}
/**
* Clear the current authentication token if set.
*/
clearAuth() {
this.sync.clearAuth();
}
/**
* @internal
*/
setAdminAuth(token: string) {
this.adminAuth = token;
if (this.closed) {
throw new Error("ConvexReactClient has already been closed.");
}
if (this.cachedSync) {
this.sync.setAdminAuth(token);
}
}
/**
* Construct a new {@link Watch} on a Convex query function.
*
* **Most application code should not call this method directly. Instead use
* the `useQuery` hook generated by `npx convex codegen`.**
*
* @param name - The name of the query function.
* @param args - The arguments to the query.
* @returns The {@link Watch} object.
*/
watchQuery<Name extends QueryNames<API>>(
name: Name,
...args: Parameters<NamedQuery<API, Name>>
): Watch<NamedQuery<API, Name>> {
return {
onUpdate: callback => {
const { queryToken, unsubscribe } = this.sync.subscribe(
name as string,
args
);
const currentListeners = this.listeners.get(queryToken);
if (currentListeners !== undefined) {
currentListeners.add(callback);
} else {
this.listeners.set(queryToken, new Set([callback]));
}
return () => {
if (this.closed) {
return;
}
const currentListeners = this.listeners.get(queryToken)!;
currentListeners.delete(callback);
if (currentListeners.size == 0) {
this.listeners.delete(queryToken);
}
unsubscribe();
};
},
localQueryResult: () => {
// Use the cached client because we can't have a query result if we don't
// even have a client yet!
if (this.cachedSync) {
return this.cachedSync.localQueryResult(
name as string,
args
) as ReturnType<NamedQuery<API, Name>>;
}
return undefined;
},
};
}
/**
* Construct a new {@link ReactMutation}.
*
* @param name - The name of the Mutation.
* @returns The {@link ReactMutation} object with that name.
*/
mutation<Name extends MutationNames<API>>(
name: Name
): ReactMutation<API, Name> {
return createMutation(name, () => this.sync);
}
/**
* Close any network handles associated with this client and stop all subscriptions.
*
* Call this method when you're done with a {@link ConvexReactClient} to
* dispose of its sockets and resources.
*
* @returns A `Promise` fulfilled when the connection has been completely closed.
*/
async close(): Promise<void> {
this.closed = true;
// Prevent outstanding React batched updates from invoking listeners.
this.listeners = new Map();
if (this.cachedSync) {
const sync = this.cachedSync;
this.cachedSync = undefined;
await sync.close();
}
}
private transition(updatedQueries: QueryToken[]) {
ReactDOM.unstable_batchedUpdates(() => {
for (const queryToken of updatedQueries) {
const callbacks = this.listeners.get(queryToken);
if (callbacks) {
for (const callback of callbacks) {
callback();
}
}
}
});
}
}
const ConvexContext = React.createContext<ConvexReactClient<any>>(
undefined as unknown as ConvexReactClient<any> // in the future this will be a mocked client for testing
);
/**
* Get the {@link ConvexReactClient} within a React component.
*
* This relies on the {@link ConvexProvider} being above in the React component tree.
*
* If you're using code generation, use the `useConvex` function in
* `convex/_generated/react.js` which is typed for your API.
*
* @returns The active {@link ConvexReactClient} object, or `undefined`.
*
* @public
*/
export function useConvexGeneric<
API extends GenericAPI
>(): ConvexReactClient<API> {
return useContext(ConvexContext);
}
/**
* Provides an active Convex {@link ConvexReactClient} to descendants of this component.
*
* Wrap your app in this component to use Convex hooks `useQuery`,
* `useMutation`, and `useConvex`.
*
* @param props - an object with a `client` property that refers to a {@link ConvexReactClient}.
*
* @public
*/
export const ConvexProvider: React.FC<{
client: ConvexReactClient<any>;
children?: React.ReactNode;
}> = ({ client, children }) => {
return React.createElement(
ConvexContext.Provider,
{ value: client },
children
);
};
/**
* Load a reactive query within a React component.
*
* This React hook contains internal state that will cause a rerender
* whenever the query result changes.
*
* Throws an error if not used under {@link ConvexProvider}.
*
* If you're using code generation, use the `useQuery` function in
* `convex/_generated/react.js` which is typed for your API.
*
* @param name - The name of the query function.
* @param args - The arguments to the query function.
* @returns `undefined` if loading and the query's return value otherwise.
*
* @public
*/
export function useQueryGeneric<
API extends GenericAPI,
Name extends QueryNames<API>
>(
name: Name,
...args: Parameters<NamedQuery<API, Name>>
): ReturnType<NamedQuery<API, Name>> | undefined {
const convex = useContext(ConvexContext);
if (convex === undefined) {
throw new Error(
"Could not find Convex client! `useQuery` must be used in the React component " +
"tree under `ConvexProvider`. Did you forget it? " +
"See https://docs.convex.dev/quick-start#set-up-convex-in-your-react-app"
);
}
const subscription = useMemo(
() => {
const watch = convex.watchQuery(name, ...args);
return {
getCurrentValue: () => watch.localQueryResult(),
subscribe: (callback: () => void) => watch.onUpdate(callback),
};
},
// ESLint doesn't like that we're stringifying the args. We do this because
// we want to avoid recreating the subscription if the args are a different
// object that serializes to the same result.
// eslint-disable-next-line react-hooks/exhaustive-deps
[name, convex, JSON.stringify(convexToJson(args))]
);
const queryResult = useSubscription(subscription);
return queryResult;
}
/**
* Construct a new {@link ReactMutation}.
*
* Mutation objects can be called like functions to request execution of the
* corresponding Convex function, or further configured with
* [optimistic updates](https://docs.convex.dev/using/optimistic-updates).
*
* The value returned by this hook is stable across renders, so it can be used
* by React dependency arrays and memoization logic relying on object identity
* without causing rerenders.
*
* If you're using code generation, use the `useMutation` function in
* `convex/_generated/react.js` which is typed for your API.
*
* Throws an error if not used under {@link ConvexProvider}.
*
* @param name - The name of the mutation.
* @returns The {@link ReactMutation} object with that name.
*
* @public
*/
export function useMutationGeneric<
API extends GenericAPI,
Name extends MutationNames<API>
>(name: Name): ReactMutation<API, Name> {
const convex = useContext(ConvexContext);
if (convex === undefined) {
throw new Error(
"Could not find Convex client! `useMutation` must be used in the React component " +
"tree under `ConvexProvider`. Did you forget it? " +
"See https://docs.convex.dev/quick-start#set-up-convex-in-your-react-app"
);
}
return useMemo(() => convex.mutation(name), [convex, name]);
}
/**
* Internal type helper used by Convex code generation.
*
* Used to give {@link useQueryGeneric} a type specific to your API.
* @public
*/
export type UseQueryForAPI<API extends GenericAPI> = <
Name extends QueryNames<API>
>(
name: Name,
...args: Parameters<NamedQuery<API, Name>>
) => ReturnType<NamedQuery<API, Name>> | undefined;
/**
* Internal type helper used by Convex code generation.
*
* Used to give {@link useMutationGeneric} a type specific to your API.
* @public
*/
export type UseMutationForAPI<API extends GenericAPI> = <
Name extends MutationNames<API>
>(
name: Name
) => ReactMutation<API, Name>;
/**
* Internal type helper used by Convex code generation.
*
* Used to give {@link useConvexGeneric} a type specific to your API.
* @public
*/
export type UseConvexForAPI<API extends GenericAPI> =
() => ConvexReactClient<API>;