convex
Version:
Client for the Convex Cloud
727 lines (684 loc) • 22.7 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)
* - [usePaginatedQuery](https://docs.convex.dev/generated-api/react#usepaginatedquery)
* - [useQueries](https://docs.convex.dev/generated-api/react#usequeries)
*
* If you aren't using code generation, you can use these untyped hooks instead:
* - {@link useQueryGeneric}
* - {@link useMutationGeneric}
* - {@link useConvexGeneric}
* - {@link usePaginatedQueryGeneric}
* - {@link useQueriesGeneric}
*
* ### 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,
QueryNames,
ActionNames,
NamedMutation,
NamedQuery,
NamedAction,
} from "../browser/index.js";
import type { OptimisticUpdate, QueryToken } from "../browser/index.js";
import React, { useContext, useMemo } from "react";
import { convexToJson } from "../values/index.js";
import ReactDOM from "react-dom";
import { useSubscription } from "./use_subscription.js";
import { ClientConfiguration } from "../browser/client_config.js";
import { QueryJournal } from "../browser/sync/protocol.js";
import { ConnectionState } from "../browser/sync/client.js";
export * from "./use_paginated_query.js";
export {
useQueriesGeneric,
type RequestForQueries,
type UseQueriesForAPI,
} from "./use_queries.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>>> {
assertNotAccidentalArgument(args);
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;
}
/**
* An interface to execute a Convex action on the server.
*
* @public
*/
export interface ReactAction<
API extends GenericAPI,
Name extends ActionNames<API>
> {
/**
* Execute the function on the server, returning a `Promise` of its return value.
*
* @param args - Arguments for the function to pass up to the server.
* @returns The return value of the server-side function call.
* @public
*/
(...args: Parameters<NamedAction<API, Name>>): Promise<
ReturnType<NamedAction<API, Name>>
>;
}
function createAction<API extends GenericAPI, Name extends ActionNames<API>>(
name: Name,
sync: () => InternalConvexClient
): ReactAction<API, Name> {
return function (
...args: Parameters<NamedAction<API, Name>>
): Promise<ReturnType<NamedAction<API, Name>>> {
return sync().action(name, args);
};
}
/**
* A watch on the output of a Convex query function.
*
* @public
*/
export interface Watch<T> {
/**
* 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(): T | undefined;
/**
* Get the current {@link browser.QueryJournal} for this query.
*
* If we have not yet received a result for this query, this will be `undefined`.
*/
journal(): QueryJournal | 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.
* @param journal - An (optional) {@link browser.QueryJournal} to use while
* executing this query. Note that if this query function with these arguments
* has already been requested, the journal will have no effect.
* @returns The {@link Watch} object.
*/
watchQuery<Name extends QueryNames<API>>(
name: Name,
args: Parameters<NamedQuery<API, Name>>,
journal?: QueryJournal
): Watch<ReturnType<NamedQuery<API, Name>>> {
// `watchQuery` used to collect the arguments with a rest operator
// (like `...args`). Double check that it's an array to make sure developers
// have updated their code.
if (!Array.isArray(args)) {
throw new Error(
`Query arguments to \`ConvexReactClient.watchQuery\` must be an array. Received ${args}.`
);
}
return {
onUpdate: callback => {
const { queryToken, unsubscribe } = this.sync.subscribe(
name as string,
args,
journal
);
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, args) as ReturnType<
NamedQuery<API, Name>
>;
}
return undefined;
},
journal: () => {
if (this.cachedSync) {
return this.cachedSync.queryJournal(name, args);
}
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);
}
/**
* Construct a new {@link ReactAction}
*
* @param name - The name of the Action.
* @returns The {@link ReactAction} object with that name.
*/
action<Name extends ActionNames<API>>(name: Name): ReactAction<API, Name> {
return createAction(name, () => this.sync);
}
/**
* Get the current {@link ConnectionState} between the client and the Convex
* backend.
*
* @returns The {@link ConnectionState} with the Convex backend.
*/
connectionState(): ConnectionState {
return this.sync.connectionState();
}
/**
* 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]);
}
/**
* Construct a new {@link ReactAction}.
*
* Action objects can be called like functions to request execution of the
* corresponding Convex function.
*
* 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 `useAction` 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 action.
* @returns The {@link ReactAction} object with that name.
*
* @public
*/
export function useActionGeneric<
API extends GenericAPI,
Name extends ActionNames<API>
>(name: Name): ReactAction<API, Name> {
const convex = useContext(ConvexContext);
if (convex === undefined) {
throw new Error(
"Could not find Convex client! `useAction` 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.action(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 useMutationGeneric} a type specific to your API.
* @public
*/
export type UseActionForAPI<API extends GenericAPI> = <
Name extends ActionNames<API>
>(
name: Name
) => ReactAction<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>;
// When a function is called with a single argument that looks like a
// React SyntheticEvent it was likely called as an event handler.
function assertNotAccidentalArgument(args: any[]) {
if (args.length !== 1) return;
const [value] = args;
// these are properties of a React.SyntheticEvent
// https://reactjs.org/docs/events.html
if (
typeof value === "object" &&
"bubbles" in value &&
"persist" in value &&
"isDefaultPrevented" in value
) {
throw new Error(
`Convex function called with SyntheticEvent object. Did you use a Convex function as an event handler directly? Event handlers like onClick receive an event object as their first argument. These SyntheticEvent objects are not valid Convex values. Try wrapping the function like \`const handler = () => myMutation();\` and using \`handler\` in the event handler.`
);
}
}