Skip to main content

React Query

Tramvai provides a complete integration for the awesome @tanstack/react-query library. React Query is a perfect solution for data fetching which can significantly improve DX and UX in your application.

Currently supported @tanstack/react-query version is ^4.7.1

Getting Started

Installation

You need to install @tramvai/react-query and @tramvai/module-react-query packages

npx tramvai add @tramvai/react-query
npx tramvai add @tramvai/module-react-query

And connect module to the project

import { createApp } from '@tramvai/core';
import { ReactQueryModule } from '@tramvai/module-react-query';

createApp({
name: 'tincoin',
modules: [ReactQueryModule],
});

Quick Start

⌛ Create a new query:

import { createQuery } from '@tramvai/react-query';
// @tramvai/module-http-client needed
import { HTTP_CLIENT } from '@tramvai/tokens-http-client';

const query = createQuery({
key: ['repoData'],
fn() {
return this.deps.httpClient
.get<Record<string, any>>('https://api.github.com/repos/tinkoff/tramvai')
.then(({ payload }) => payload);
},
deps: {
httpClient: HTTP_CLIENT,
},
});

⌛ Use query in component:

import { useQuery } from '@tramvai/react-query';

const Example = () => {
const { isLoading, error, data } = useQuery(query);

if (isLoading) {
return 'Loading...';
}

if (error) {
return `An error has occurred: ${error.message}`;
}

return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong> <strong>{data.stargazers_count}</strong>{' '}
<strong>🍴 {data.forks_count}</strong>
</div>
);
};

Configuration

You can configure options for the QueryClient through the QUERY_CLIENT_DEFAULT_OPTIONS_TOKEN, e.g.:

import { provide } from '@tramvai/core';
import { QUERY_CLIENT_DEFAULT_OPTIONS_TOKEN } from '@tramvai/tokens-react-query';

const provider = provide({
provide: QUERY_CLIENT_DEFAULT_OPTIONS_TOKEN,
useValue: {},
});

Default Options

Here is QUERY_CLIENT_DEFAULT_OPTIONS_TOKEN default value:

const defaults = {
queries: {
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
};

refetchOnMount option is disabled because usually you want to prefetch query for page, and no needs to fetch it again on mount. Another options are disabled for better network requests control from the application side.

Also, default retries is disabled for actions conditions errors, because this errors are expected and retrying is meaningless.

Examples

You can find and run examples in our repo

Basic Query

import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: ['base'],
async fn() {
await sleep(100);
return 'Response from API';
},
});

export function Component() {
const { data, isLoading } = useQuery(query);

return <div>{isLoading ? 'loading...' : data}</div>;
}

Prefetching

tip

Always use page component actions static property with prefetchAction to run query for this route at server-side

import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: ['base'],
async fn() {
await sleep(100);
return 'Response from API';
},
});

export default function Page() {
const { data, isLoading } = useQuery(query);

return <div>{isLoading ? 'loading...' : data}</div>;
}

Page.actions = [query.prefetchAction()];

SPA and dynamic url parameters

For example, you have route like /blog/:article/, and a few specific urls on it - /blog/first/ and /blog/second/. From any article you can made SPA-transition to another article.

This case can be a little bit tricky for data prefetching:

  • for prefetchAction, you can't pass slug, and need to resolve it from PAGE_SERVICE_TOKEN in key and fn
  • for SPA-transitions on the same route, you need to pass slug to useQuery in component, and can resolve it from useRoute hook.

Complete example:

import { createQuery, useQuery } from '@tramvai/react-query';
import { useRoute, PAGE_SERVICE_TOKEN } from '@tramvai/module-router';

const query = createQuery({
key: (slug?: string) => {
// get slug from query options, otherwise from page service
const articleId = slug || this.deps.pageService.getCurrentRoute().params.article;

// it is important, any dynamic part of query need to be saved in key
return ['article', articleId];
},
actionNamePostfix: 'getArticleById',
async fn(slug?: string) {
// get slug from query options, otherwise from page service
const articleId = slug || this.deps.pageService.getCurrentRoute().params.article;

return this.deps.apiService.fetchArticle(articleId);
},
deps: {
apiService: API_SERVICE_TOKEN,
pageService: PAGE_SERVICE_TOKEN,
},
});

export default function Article() {
// the same data as `pageService.getCurrentRoute()` with routes changes subscription
const route = useRoute();
// after SPA-transitions, this query will be executed client-side, always with fresh slug
const { data, isLoading } = useQuery(query, route.params.article);

return <div>{isLoading ? 'loading...' : data}</div>;
}

// during the first page load, this query will be executed server-side, with actual slug from page service
Article.actions = [query.prefetchAction()];

Dependencies

Queries has full Dependency Injection support, so you can declare dependencies like in DI providers, in deps property. These dependencies will be available in the action fn and key functions фы this.deps property.

tip

When you want to prefetch query with specific parameter, the only way to do it right is to get this parameter from DI

import { createQuery, useQuery } from '@tramvai/react-query';
import { PAGE_SERVICE_TOKEN } from '@tramvai/tokens-router';
import { HTTP_CLIENT } from '@tramvai/tokens-http-client';

const query = createQuery({
key() {
return `base/${this.deps.pageService.getCurrentUrl().query.route}`;
},
actionNamePostfix: 'requestByRoute',
async fn() {
const { apiClient, pageService } = this.deps;

const { payload } = await httpClient.get<string>('api/by-route', {
query: {
route: pageService.getCurrentUrl().query.route ?? 'test',
},
});

return payload;
},
deps: {
httpClient: HTTP_CLIENT,
pageService: PAGE_SERVICE_TOKEN,
},
});

// eslint-disable-next-line import/no-default-export
export default function Component() {
const { data, isLoading } = useQuery(query);

return <div>{isLoading ? 'loading...' : data}</div>;
}

Shared Query

If you want to use same query in different components, this queries requests will be deduplicated, and response data will be shared.

import { useState, useEffect } from 'react';
import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: ['base'],
async fn() {
await sleep(100);
return 'Response from API';
},
});

const Child1 = () => {
const { isLoading, data } = useQuery(query);

return <div>Child1: {isLoading ? 'loading...' : data}</div>;
};

const Child2 = () => {
const { isLoading, data } = useQuery(query);

return <div>Child2: {isLoading ? 'loading...' : data}</div>;
};

export function Component() {
return (
<>
<Child1 />
<Child2 />
</>
);
}

Request parameters

Parameter will be available in key and fn functions as first argument. You can pass this parameter as second argument of useQuery hook.

tip

Query keys always should include specific parameters, on which the result of request depends

import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: (parameter: string) => ['api-group', parameter],
actionNamePostfix: 'requestGroup',
async fn(parameter) {
await sleep(100);
return `Response ${parameter} from API`;
},
});

const Child1 = () => {
const { isLoading, data } = useQuery(query, 'test-1');

return <div>Child1: {isLoading ? 'loading...' : data}</div>;
};

const Child2 = () => {
const { isLoading, data } = useQuery(query, 'test-2');

return <div>Child2: {isLoading ? 'loading...' : data}</div>;
};

export function Component() {
return (
<>
<Child1 />
<Child2 />
</>
);
}

Custom Query options

Default options will be used for all queries, but you can rewrite them for specific query.

import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: 'time',
async fn() {
await sleep(100);
return 'Response from API';
},
// default options for this query
queryOptions: {
refetchOnWindowFocus: true,
refetchOnMount: true,
},
});

export function Component() {
const { data } = useQuery(
// the same query with rewritten options
query.fork({
refetchInterval: 2000,
refetchIntervalInBackground: false,
})
);

return <div>{data}</div>;
}

Failed requests

React Query will retry failed requests 3 times by default with exponential backoff. You can change this behavior with queryOptions property.

import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: 'base',
async fn() {
throw Error('Error from API');
},
queryOptions: {
retry: 2,
retryDelay: 500,
},
});

export function Component() {
const { data, isLoading, isError, error } = useQuery(query);

if (isLoading) {
return <div>loading...</div>;
}

if (isError) {
return <div>error: {error!.message}</div>;
}

return <div>{data}</div>;
}

Conditions

All actions retrictions are supported:

import { createQuery, useQuery } from '@tramvai/react-query';
import { FAKE_API_CLIENT } from '../../fakeApiClient';

const query = createQuery({
key: 'base',
async fn() {
await sleep(1000);
return 'Some slow response from API';
},
conditions: {
onlyServer: true,
},
});

export function Component() {
const { data = 'no-data', isLoading } = useQuery(query);

return <div>{isLoading ? 'loading...' : data}</div>;
}

Infinite Query

import { createInfiniteQuery, useInfiniteQuery } from '@tramvai/react-query';
import { HTTP_CLIENT } from '@tramvai/tokens-http-client';

interface Response {
nextPage?: number;
list: string[];
}

const query = createInfiniteQuery({
key: 'list',
async fn(_, start = 0) {
const { payload } = await this.deps.httpClient.get<Response>('api/list', {
query: {
count: 30,
start,
},
});

return payload;
},
getNextPageParam: (page: Response) => {
return page.nextPage;
},
deps: {
httpClient: HTTP_CLIENT,
},
infiniteQueryOptions: {},
});

export function Component() {
const { data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery(query);

if (isLoading) {
return <>loading...</>;
}

return (
<div>
<div>
{data!.pages.map((page) => {
return page.list.map((entry) => {
return <div key={entry}>{entry}</div>;
});
})}
</div>
{hasNextPage && (
<button type="button" onClick={() => fetchNextPage()}>
Load more
</button>
)}
</div>
);
}

Mutation

import { createMutation, useMutation } from '@tramvai/react-query';
import { HTTP_CLIENT } from '@tramvai/tokens-http-client';

const mutation = createMutation({
key: 'post',
async fn(_, data: string) {
const { payload } = await this.deps.httpClient.post('api/post', {
body: {
data,
},
});

return payload;
},
deps: {
httpClient: HTTP_CLIENT,
},
});

export function Component() {
const { isLoading, mutate } = useMutation(mutation);

if (isLoading) {
return <>loading...</>;
}

return (
<button type="button" onClick={() => mutate('test')}>
Send data
</button>
);
}

AbortController

Similar to Actions AbortController, abortSignal and abortController for current execution context available in fn context (this):

const query = createQuery({
key: ['abortable'],
async fn() {
const signal = this.abortSignal;

return this.deps.httpClient.request({ path: '/api', signal });
},
deps: {
httpClient: HTTP_CLIENT_TOKEN,
},
});

This signal has subscription to react-query cancellation signal, see example in use-query-cancel example

Use @tanstack/react-query directly

caution

Prefer to use methods from the @tramvai/react-query as it can work both with the Query wrapper and the query options to @tanstack/react-query itself

You can get QueryClient from di by token QUERY_CLIENT_TOKEN or using method useQueryClient in React-components.

To convert wrapped Query object to object acceptable by @tanstack/react-query use method raw of the Query instance.

API Reference

Query

A wrapper around react-query options with tramvai integration.

fork

Create new Query from an existing query with option to override settings.

import { createQuery } from '@tramvai/react-query';

const query = createQuery();

const newQuery = query.fork({
refetchInterval: 2000,
refetchIntervalInBackground: false,
});

prefetchAction

Return a tramvai action that can be used to prefetch current query

export default function PageComponent() {
const { data, isLoading } = useQuery(query);

return <div>{isLoading ? 'loading...' : data}</div>;
}

PageComponent.actions = [query.prefetchAction()];

fetchAction

Return a tramvai action that can be used to fetch current query

const action = declareAction({
name: 'action',
async fn() {
const result = await this.executeAction(query.fetchAction());

console.log('__action__', result);
},
});

raw

Might be used when the raw query options is needed. The result can be passed to the underlying methods of @tanstack/react-query lib in cases when @tramvai/react-query doesn't provide appropriate wrapper. This method is used internally in the lib to redirect calls to @tanstack/react-query.

createQuery

Allows you to create a Query object that can later be used in components using useQuery. Used to execute single data retrieval requests.

import { createQuery } from '@tramvai/react-query';

const query = createQuery({
key: ['base'],
fn: async (_, { apiClient }) => {
const { payload } = await apiClient.get('api/base');

return payload;
},
deps: {
apiClient: TINKOFF_API_SERVICE,
},
});

Unique query parameters

To create a generic query that takes parameters for a query, you must return a unique key, you can read more about this in the official documentation section Query Keys

As a parameter key you can use:

  • a string, such as key: 'query-name'
  • an array where any serializable data can be used as elements, for example key: ['query-name', false, { bar: 'baz }]
  • a function that takes the parameters with which query is called and returns a string - key: (this: { deps }, options) => 'query-name'. Where through this.deps you can get resolved deps for the query.
  • a function that accepts parameters, with which query is called, and returns an array, where any serializable data can be used as elements - key: (this: { deps }, options) => ['query-name', options, { bar: 'baz' }]
note

If you pass parameter key as a function, you should pass actionNamePostfix to avoid duplicates in the server-client cache

import { createQuery, useQuery } from '@tramvai/react-query';

const query = createQuery({
key: (id: number) => ['user', id],
actionNamePostfix: 'requestUser',
async fn(id) {
const { apiClient } = this.deps;
const { payload } = await apiClient.get(`api/user/${id}`);

return payload;
},
deps: {
apiClient: TINKOFF_API_SERVICE,
},
});

export function Component({ id }) {
const { data, isLoading } = useQuery(query, id);

return <div>{isLoading ? 'loading...' : data}</div>;
}

useQuery

React hook for working with Query object

react-query docs

import { useQuery } from '@tramvai/react-query';

export function Component() {
const { data, isLoading } = useQuery(query);

return <div>{isLoading ? 'loading...' : data}</div>;
}

useQueries

React Hook for working with the list of Query objects

react-query docs

import { useQueries } from '@tramvai/react-query';

export function Component() {
const [{ data: data1, isLoading: isLoading1 }, { data: data2, isLoading: isLoading2 }] =
useQueries([query1, query2]);

return (
<div>
<div>{isLoading1 ? 'loading1...' : data1}</div>
<div>{isLoading2 ? 'loading2...' : data2}</div>
</div>
);
}

createInfiniteQuery

Creates an InfiniteQuery object that can later be used in components using useInfiniteQuery. It is used to execute queries to obtain a sequence of data that can be loaded as the component runs.

import { createInfiniteQuery } from '@tramvai/react-query';

const query = createInfiniteQuery({
key: 'list',
async fn(_, start = 0) {
const { apiClient } = this.deps;
const { payload } = await apiClient.get<Response>('api/list', {
query: {
count: 30,
start,
},
});

return payload;
},
getNextPageParam: (page: Response) => {
return page.nextPage;
},
deps: {
apiClient: TINKOFF_API_SERVICE,
},
});

useInfiniteQuery

React hook for working with the InfiniteQuery object

react-query docs

import { useInfiniteQuery } from '@tramvai/react-query';

export function Component() {
const { data, isLoading, fetchNextPage, hasNextPage } = useInfiniteQuery(query);

if (isLoading) {
return 'loading...';
}

return (
<div>
<div>
{data.pages.map((page) => {
return page.list.map((entry) => {
return <div key={entry}>{entry}</div>;
});
})}
</div>
{hasNextPage && (
<button type="button" onClick={() => fetchNextPage()}>
Load more
</button>
)}
</div>
);
}

createMutation

Creates a Mutation object that can later be used in components using useMutation. Used to send and modify data in the api.

import { createMutation } from '@tramvai/react-query';

const mutation = createMutation({
key: 'post',
async fn(_, data: string) {
const { apiClient } = this.deps;
const { payload } = await apiClient.post('api/post', {
body: {
data,
},
});

return payload;
},
deps: {
apiClient: TINKOFF_API_SERVICE,
},
});

useMutation

React hook for working with the Mutation object

react-query docs

import { useMutation } from '@tramvai/react-query';

export function Component() {
const { isLoading, mutate } = useMutation(mutation);

if (isLoading) {
return 'loading...';
}

return (
<button type="button" onClick={() => mutate('test')}>
Send data
</button>
);
}