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
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 fromPAGE_SERVICE_TOKENinkeyandfn - for SPA-transitions on the same route, you need to pass slug to
useQueryin component, and can resolve it fromuseRoutehook.
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 as this.deps property.
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.
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
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
queryis called and returns a string -key: (this: { deps }, options) => 'query-name'. Where throughthis.depsyou can get resolved deps for the query. - a function that accepts parameters, with which
queryis called, and returns an array, where any serializable data can be used as elements -key: (this: { deps }, options) => ['query-name', options, { bar: 'baz' }].
If you pass parameter key as a function, you should pass actionNamePostfix to avoid duplicates in the server-client cache
Also, this will be undefined for arrow functions.
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
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
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
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
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>
);
}