Skip to content

TypeScript-first expansion pack for TanStack Query that gives you Protobuf superpowers.

License

Notifications You must be signed in to change notification settings

drice-buf/connect-query-es

 
 

Repository files navigation

Connect-Query

License Build NPM Version NPM Version

Connect-Query is an wrapper around TanStack Query (react-query), written in TypeScript and thoroughly tested. It enables effortless communication with servers that speak the Connect Protocol.

Quickstart

Install

npm install @connectrpc/connect-query @connectrpc/connect-web

Note: If you are using something that doesn't automatically install peerDependencies (npm older than v7), you'll want to make sure you also have @bufbuild/protobuf, @connectrpc/connect, and @tanstack/react-query installed. @connectrpc/connect-web is required for defining the transport to be used by the client.

Usage

Connect-Query will immediately feel familiar to you if you've used TanStack Query. It provides a similar API, but instead takes a definition for your endpoint and returns a typesafe API for that endpoint.

First, make sure you've configured your provider and query client:

import { createConnectTransport } from "@connectrpc/connect-web";
import { TransportProvider } from "@connectrpc/connect-query";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const finalTransport = createConnectTransport({
  baseUrl: "https://demo.connectrpc.com",
});

const queryClient = new QueryClient();

function App() {
  return (
    <TransportProvider transport={finalTransport}>
      <QueryClientProvider client={queryClient}>
        <YourApp />
      </QueryClientProvider>
    </TransportProvider>
  );
}

With configuration completed, you can now use the useQuery hook to make a request:

import { useQuery } from '@connectrpc/connect-query';
import { example } from 'your-generated-code/example-ExampleService_connectquery';

export const Example: FC = () => {
  const { data } = useQuery(example);
  return <div>{data}</div>;
};

That's it!

The code generator does all the work of turning your Protobuf file into something you can easily import. TypeScript types all populate out-of-the-box. Your documentation is also converted to TSDoc.

One of the best features of this library is that once you write your schema in Protobuf form, the TypeScript types are generated and then inferred. You never again need to specify the types of your data since the library does it automatically.

Generated Code

This example shows the best developer experience using code generation. Here's what that generated code looks like:

import { MethodKind } from "@bufbuild/protobuf";
import { ExampleRequest, ExampleResponse } from "./example_pb.js";

export const example = {
  name: "Example",
  kind: MethodKind.Unary,
  I: ExampleRequest,
  O: ExampleResponse,
  service: {
    typeName: "your.company.com.example.v1.ExampleService",
  },
};

The above code doesn't have to be generated and can be manually used to describe any given endpoint.

For more information on code generation, see the documentation for protoc-gen-connect-query.

Connect-Query API

MethodUnaryDescriptor

A type that describes a single unary method. It describes the following properties:

  • name: The name of the method.
  • kind: The kind of method. In this case, it's usually MethodKind.Unary.
  • I: The input message type.
  • O: The output message type.
  • service.typeName: The fully qualified name of the service the method exists on.

This type is core to how connect-query can stay lightweight and limit the amount of code actually generated. The descriptor is expected to be passed to almost all the methods in this library.

TransportProvider

const TransportProvider: FC<
  PropsWithChildren<{
    transport: Transport;
  }>
>;

TransportProvider is the main mechanism by which Connect-Query keeps track of the Transport used by your application.

Broadly speaking, "transport" joins two concepts:

  1. The protocol of communication. For this there are two options: the Connect Protocol, or the gRPC-Web Protocol.
  2. The protocol options. The primary important piece of information here is the baseUrl, but there are also other potentially critical options like request credentials, wire serialization options, or protocol-specific options like Connect's support for HTTP GET.

With these two pieces of information in hand, the transport provides the critical mechanism by which your app can make network requests.

To learn more about the two modes of transport, take a look at the Connect-Web documentation on choosing a protocol.

To get started with Connect-Query, simply import a transport (either createConnectTransport or createGrpcWebTransport from @connectrpc/connect-web) and pass it to the provider.

A common use case for the transport is to add headers to requests (like auth tokens, etc). You can do this with a custom interceptor.

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { TransportProvider } from "@connectrpc/connect-query";

const queryClient = new QueryClient();

export const App = () => {
  const transport = createConnectTransport({
    baseUrl: "<your baseUrl here>",
    interceptors: [(next) => (request) => {
      request.header.append("some-new-header", "some-value");
      // Add your headers here
      return next(request);
    }],
  });
  return (
    <TransportProvider transport={transport}>
      <QueryClientProvider client={queryClient}>
         <YourApp />
      </QueryClientProvider>
    </TransportProvider>
  );
}

For more details about what you can do with the transport, see the Connect-Web documentation.

useTransport

const useTransport: () => Transport;

Use this helper to get the default transport that's currently attached to the React context for the calling component.

useQuery

function useQuery<I extends Message<I>, O extends Message<O>>(
  methodSig: MethodUnaryDescriptor<I, O>,
  input?: DisableQuery | PartialMessage<I>,
  options?: {
    transport?: Transport;
    callOptions?: CallOptions;
  } & UseQueryOptions,
): UseQueryResult<O, ConnectError>;

The useQuery hook is the primary way to make a unary request. It's a wrapper around TanStack Query's useQuery hook, but it's preconfigured with the correct queryKey and queryFn for the given method.

Any additional options you pass to useQuery will be merged with the options that Connect-Query provides to @tanstack/react-query. This means that you can pass any additional options that TanStack Query supports.

useSuspenseQuery

Identical to useQuery but mapping to the useSuspenseQuery hook from TanStack Query. This includes the benefits of narrowing the resulting data type (data will never be undefined).

useInfiniteQuery

function useInfiniteQuery<
  I extends Message<I>,
  O extends Message<O>,
  ParamKey extends keyof PartialMessage<I>,
  Input extends PartialMessage<I> & Required<Pick<PartialMessage<I>, ParamKey>>,
>(
  methodSig: MethodUnaryDescriptor<I, O>,
  input: DisableQuery | Input,
  options: {
    pageParamKey: ParamKey;
    transport?: Transport;
    callOptions?: CallOptions;
    getNextPageParam: GetNextPageParamFunction<PartialMessage<I>[ParamKey], O>;
  },
): UseInfiniteQueryResult<InfiniteData<O>, ConnectError>;

The useInfiniteQuery is a wrapper around TanStack Query's useInfiniteQuery hook, but it's preconfigured with the correct queryKey and queryFn for the given method.

There are some required options for useInfiniteQuery, primarily pageParamKey and getNextPageParam. These are required because Connect-Query doesn't know how to paginate your data. You must provide a mapping from the output of the previous page and getting the next page. All other options passed to useInfiniteQuery will be merged with the options that Connect-Query provides to @tanstack/react-query. This means that you can pass any additional options that TanStack Query supports.

useSuspenseInfiniteQuery

Identical to useInfiniteQuery but mapping to the useSuspenseInfiniteQuery hook from TanStack Query. This includes the benefits of narrowing the resulting data type (data will never be undefined).

useMutation

function useMutation<I extends Message<I>, O extends Message<O>>(
  methodSig: MethodUnaryDescriptor<I, O>,
  options?: {
    transport?: Transport;,
    callOptions?: CallOptions;
  },
): UseMutationResult<O, ConnectError, PartialMessage<I>>

The useMutation is a wrapper around TanStack Query's useMutation hook, but it's preconfigured with the correct mutationFn for the given method.

Any additional options you pass to useMutation will be merged with the options that Connect-Query provides to @tanstack/react-query. This means that you can pass any additional options that TanStack Query supports.

createConnectQueryKey

function createConnectQueryKey<I extends Message<I>, O extends Message<O>>(
  methodDescriptor: Pick<MethodUnaryDescriptor<I, O>, "I" | "name" | "service">,
  input?: DisableQuery | PartialMessage<I> | undefined,
): ConnectQueryKey<I>;

This helper is useful to manually compute the queryKey sent to TanStack Query. This function has no side effects.

createConnectInfiniteQueryKey

function createConnectInfiniteQueryKey<
  I extends Message<I>,
  O extends Message<O>,
>(
  methodDescriptor: Pick<MethodUnaryDescriptor<I, O>, "I" | "name" | "service">,
  input: DisableQuery | PartialMessage<I>,
  pageParamKey: keyof PartialMessage<I>,
): ConnectInfiniteQueryKey<I>;

This function is not really necessary unless you are manually creating infinite query keys. When invalidating queries, it usually makes more sense to use the createConnectQueryKey function instead since it will also invalidate the regular queries (as well as the infinite queries).

callUnaryMethod

function callUnaryMethod<I extends Message<I>, O extends Message<O>>(
  methodType: MethodUnaryDescriptor<I, O>,
  input: PartialMessage<I> | undefined,
  {
    callOptions,
    transport,
  }: {
    transport: Transport;
    callOptions?: CallOptions | undefined;
  },
): Promise<O>;

This API allows you to directly call the method using the provided transport. Use this if you need to manually call a method outside of the context of a React component, or need to call it where you can't use hooks.

createProtobufSafeUpdater

Creates a typesafe updater that can be used to update data in a query cache. Used in combination with a queryClient.

import { createProtobufSafeUpdater } from '@connectrpc/connect-query';
import { useQueryClient } from "@tanstack/react-query";

...
const queryClient = useQueryClient();
queryClient.setQueryData(
  createConnectQueryKey(example),
  createProtobufSafeUpdater(example, (prev) => {
    return {
      ...prev,
      completed: true,
    };
  })
);

createQueryOptions

function createQueryOptions<I extends Message<I>, O extends Message<O>>(
  methodSig: MethodUnaryDescriptor<I, O>,
  input: DisableQuery | PartialMessage<I> | undefined,
  {
    transport,
    callOptions,
  }: ConnectQueryOptions & {
    transport: Transport;
  },
): {
  queryKey: ConnectQueryKey<I>;
  queryFn: QueryFunction<O, ConnectQueryKey<I>>;
  enabled: boolean;
};

A functional version of the options that can be passed to the useQuery hook from @tanstack/react-query. When called, it will return the appropriate queryKey, queryFn, and enabled flag. This is useful when interacting with useQueries API or queryClient methods (like ensureQueryData, etc).

An example of how to use this function with useQueries:

import { useQueries } from "@tanstack/react-query";
import { createQueryOptions, useTransport } from "@connectrpc/connect-query";
import { example } from "your-generated-code/example-ExampleService_connectquery";

const MyComponent = () => {
  const transport = useTransport();
  const [query1, query2] = useQueries([
    createQueryOptions(example, { sentence: "First query" }, { transport }),
    createQueryOptions(example, { sentence: "Second query" }, { transport }),
  ]);
  ...
};

createInfiniteQueryOptions

function createInfiniteQueryOptions<
  I extends Message<I>,
  O extends Message<O>,
  ParamKey extends keyof PartialMessage<I>,
  Input extends PartialMessage<I> & Required<Pick<PartialMessage<I>, ParamKey>>,
>(
  methodSig: MethodUnaryDescriptor<I, O>,
  input: DisableQuery | Input,
  {
    transport,
    getNextPageParam,
    pageParamKey,
    callOptions,
  }: ConnectInfiniteQueryOptions<I, O, ParamKey>,
): {
  getNextPageParam: ConnectInfiniteQueryOptions<
    I,
    O,
    ParamKey
  >["getNextPageParam"];
  queryKey: ConnectInfiniteQueryKey<I>;
  queryFn: QueryFunction<
    O,
    ConnectInfiniteQueryKey<I>,
    PartialMessage<I>[ParamKey]
  >;
  initialPageParam: PartialMessage<I>[ParamKey];
  enabled: boolean;
};

A functional version of the options that can be passed to the useInfiniteQuery hook from @tanstack/react-query.When called, it will return the appropriate queryKey, queryFn, and enabled flags, as well as a few other parameters required for useInfiniteQuery. This is useful when interacting with some queryClient methods (like ensureQueryData, etc).

ConnectQueryKey

type ConnectQueryKey<I extends Message<I>> = [
  serviceTypeName: string,
  methodName: string,
  input: PartialMessage<I>,
];

TanStack Query requires query keys in order to decide when the query should automatically update.

QueryKeys in TanStack Query are usually arbitrary, but Connect-Query uses the approach of creating a query key that begins with the least specific information: the service's typeName, followed by the method name, and ending with the most specific information to identify a particular request: the input message itself.

For example, a query key might look like this:

[
  "example.v1.ExampleService",
  "GetTodos",
  { id: "0fdf2ebe-9a0c-4366-9772-cfb21346c3f9" },
];

For example, a partial query key might look like this:

["example.v1.ExampleService", "GetTodos"];

ConnectInfiniteQueryKey

Similar to ConnectQueryKey, but for infinite queries.

type ConnectInfiniteQueryKey<I extends Message<I>> = [
  serviceTypeName: string,
  methodName: string,
  input: PartialMessage<I>,
  "infinite",
];

Testing

Connect-query (along with all other javascript based connect packages) can be tested with the createRouterTransport function from @connectrpc/connect. This function allows you to create a transport that can be used to test your application without needing to make any network requests. We also have a dedicated package, @connectrpc/connect-playwright for testing within playwright.

For playwright, you can see a sample test here.

Frequently Asked Questions

How do I pass other TanStack Query options?

Each function that interacts with TanStack Query also provides for options that can be passed through.

import { useQuery } from '@connectrpc/connect-query';
import { example } from 'your-generated-code/example-ExampleService_connectquery';

export const Example: FC = () => {
  const { data } = useQuery(example, undefined, {
    // These are typesafe options that are passed to underlying TanStack Query.
    refetchInterval: 1000,
  });
  return <div>{data}</div>;
};

Why was this changed from the previous version of Connect-Query?

Originally, all we did was pass options to TanStack Query. This was done as an intentional way to keep ourselves separate from TanStack Query. However, as usage increased, it became obvious that were still tied to the API of TanStack Query, and it only meant that we increased the burden on the developer to understand that underlying connection. This new API removes most of that burden and reduces the surface area of the API significantly.

Is this ready for production?

Buf has been using Connect-Query in production for some time. Also, there is 100% mandatory test coverage in this project which covers quite a lot of edge cases.

Using BigInt with RPC inputs

Since Connect-Query use the inputs as keys for the query, if you have a field with type int64, those fields will cause serialization problems. For this reason, Connect-Query ships with defaultOptions that can be passed to the QueryClient to make sure serializing BigInt fields is done properly:

import { defaultOptions } from "@connectrpc/connect-query";
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient({ defaultOptions });

What is Connect-Query's relationship to Connect-Web and Protobuf-ES?

Here is a high-level overview of how Connect-Query fits in with Connect-Web and Protobuf-ES:

Expand to see a detailed dependency graph

connect-query_dependency_graph

Your Protobuf files serve as the primary input to the code generators protoc-gen-connect-query and protoc-gen-es. Both of these code generators also rely on primitives provided by Protobuf-ES. The Buf CLI produces the generated output. The final generated code uses Transport from Connect-Web and generates a final Connect-Query API.

What is Transport

Transport is a regular JavaScript object with two methods, unary and stream. See the definition in the Connect-Web codebase here. Transport defines the mechanism by which the browser can call a gRPC-web or Connect backend. Read more about Transport on the connect docs.

What if I already use Connect-Web?

You can use Connect-Web and Connect-Query together if you like!

What if I use gRPC-web?

Connect-Query also supports gRPC-web! All you need to do is make sure you call createGrpcWebTransport instead of createConnectTransport.

That said, we encourage you to check out the Connect protocol, a simple, POST-only protocol that works over HTTP/1.1 or HTTP/2. It supports server-streaming methods just like gRPC-Web, but is easy to debug in the network inspector.

Do I have to use a code generator?

No. The code generator just generates the method descriptors, but you are free to do that yourself if you wish.

What if I have a custom Transport?

If the Transport attached to React Context via the TransportProvider isn't working for you, then you can override transport at every level. For example, you can pass a custom transport directly to the lowest-level API like useQuery or callUnaryMethod.

Does this only work with React?

Connect-Query does require React, but the core (createConnectQueryKey and callUnaryMethod) is not React specific so splitting off a connect-solid-query is possible.

How do I do Prefetching?

When you might not have access to React context, you can use the create series of functions and provide a transport directly. For example:

import { say } from "./gen/eliza-ElizaService_connectquery";

function prefetch() {
  return queryClient.prefetchQuery({
    queryKey: createConnectQueryKey(say, { sentence: "Hello" }),
    queryFn: () =>
      callUnaryMethod(say, { sentence: "Hello" }, { transport: myTransport }),
  });
}

What about Streaming?

Connect-Query currently only supports Unary RPC methods, which use a simple request/response style of communication similar to GET or POST requests in REST. This is because it aligns most closely with TanStack Query's paradigms. However, we understand that there may be use cases for Server Streaming, Client Streaming, and Bidirectional Streaming, and we're eager to hear about them.

At Buf, we strive to build software that solves real-world problems, so we'd love to learn more about your specific use case. If you can provide a small, reproducible example, it will help us shape the development of a future API for streaming with Connect-Query.

To get started, we invite you to open a pull request with an example project in the examples directory of the Connect-Query repository. If you're not quite sure how to implement your idea, don't worry - we want to see how you envision it working. If you already have an isolated example, you may also provide a simple CodeSandbox or Git repository.

If you're not yet at the point of creating an example project, feel free to open an issue in the repository and describe your use case. We'll follow up with questions to better understand your needs.

Your input and ideas are crucial in shaping the future development of Connect-Query. We appreciate your input and look forward to hearing from you.

Legal

Offered under the Apache 2 license.

About

TypeScript-first expansion pack for TanStack Query that gives you Protobuf superpowers.

Resources

License

Stars

Watchers

Forks

Packages

No packages published

Languages

  • TypeScript 88.2%
  • JavaScript 11.8%