The TanStack Query (also known as react-query) adapter for Angular applications
Get rid of granular state management, manual refetching, and async spaghetti code. TanStack Query gives you declarative, always-up-to-date auto-managed queries and mutations that improve your developer experience.
✅ Observable & Signal Support
✅ Backend agnostic
✅ Dedicated Devtools
✅ Auto Caching
✅ Auto Refetching
✅ Window Focus Refetching
✅ Polling/Realtime Queries
✅ Parallel Queries
✅ Dependent Queries
✅ Mutations API
✅ Automatic Garbage Collection
✅ Paginated/Cursor Queries
✅ Load-More/Infinite Scroll Queries
✅ Request Cancellation
✅ Prefetching
✅ Offline Support
✅ Data Selectors
✅ SSR Support
Discover the innovative approach TanStack Query takes to state management, setting it apart from traditional methods. Learn about the motivation behind this design and explore its unique features here.
npm i @ngneat/query
Please be aware that the
@tanstack/query-core
package must also be installed for the functionality to operate correctly.
Inject the QueryClient
instance through the injectQueryClient()
function.
import { injectQueryClient } from '@ngneat/query';
@Injectable({ providedIn: 'root' })
export class TodosService {
#queryClient = injectQueryClient();
}
The function should run inside an injection context
Use the injectQuery
function. Using this function is similar to the official function.
import { injectQuery } from '@ngneat/query';
@Injectable({ providedIn: 'root' })
export class TodosService {
#http = inject(HttpClient);
#query = injectQuery();
getTodos() {
return this.#query({
queryKey: ['todos'] as const,
queryFn: () => {
return this.#http.get<Todo[]>(
'https://jsonplaceholder.typicode.com/todos',
);
},
});
}
}
The function should run inside an injection context
For methods that require a queryFn
parameter like
ensureQueryData
, fetchQuery
, prefetchQuery
, fetchInfiniteQuery
and prefetchInfiniteQuery
it's possible to use both Promises and Observables. See an example here.
To get an observable use the result$
property:
@Component({
standalone: true,
template: `
@if (todos.result$ | async; as result) {
@if (result.isLoading) {
<p>Loading</p>
}
@if (result.isSuccess) {
<p>{{ result.data[0].title }}</p>
}
@if (result.isError) {
<p>Error</p>
}
}
`,
})
export class TodosPageComponent {
todos = inject(TodosService).getTodos();
}
To get a signal use the result
property:
@Component({
standalone: true,
template: `
@if (todos().isLoading) {
Loading
}
@if (todos().data; as data) {
<p>{{ data[0].title }}</p>
}
@if (todos().isError) {
<p>Error</p>
}
`,
})
export class TodosPageComponent {
todos = inject(TodosService).getTodos().result;
}
If you inline query options into query
, you'll get automatic type inference. However, you might want to extract the query options into a separate function to share them between query
and e.g. prefetchQuery
. In that case, you'd lose type inference. To get it back, you can use queryOptions
helper:
import { queryOptions } from '@ngneat/query';
function groupOptions() {
return queryOptions({
queryKey: ['groups'] as const,
queryFn: fetchGroups,
staleTime: 5 * 1000,
});
}
Further, the queryKey
returned from queryOptions
knows about the queryFn
associated with it, and we can leverage that type information to make functions like queryClient.getQueryData
aware of those types as well:
@Injectable({ providedIn: 'root' })
export class GroupsService {
#client = injectQueryClient();
#http = inject(HttpClient);
groupOptions = queryOptions({
queryKey: ['groups'] as const,
queryFn: () => this.#http.get(url),
staleTime: 5 * 1000,
});
getCachedGroup() {
const data = this.#client.getQueryData(this.groupOptions.queryKey);
// ^? const data: Group[] | undefined
return data;
}
}
Use the injectInfiniteQuery
function. Using this function is similar to the official function.
import { injectInfiniteQuery } from '@ngneat/query';
@Injectable({ providedIn: 'root' })
export class PostsService {
#query = injectInfiniteQuery();
getPosts() {
return this.#query({
queryKey: ['posts'],
queryFn: ({ pageParam }) => getProjects(pageParam),
initialPageParam: 0,
getPreviousPageParam: (firstPage) => firstPage.previousId,
getNextPageParam: (lastPage) => lastPage.nextId,
});
}
}
The function should run inside an injection context
Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects. For this purpose, The library exports the `injectMutation`` function.
import { injectMutation } from '@ngneat/query';
@Injectable({ providedIn: 'root' })
export class TodosService {
#mutation = injectMutation();
#http = inject(HttpClient);
addTodo() {
return this.#mutation({
mutationFn: ({ title }) =>
this.#http.post<Todo>(`https://jsonplaceholder.typicode.com/todos`, {
title,
}),
});
}
}
The variables
in the mutationFn
callback are the variables that will be passed to the mutate
function later.
Now create your component in which you want to use your newly created service:
@Component({
template: `
<input #ref />
<button (click)="onAddTodo({ title: ref.value })">Add todo</button>
@if (addTodo.result$ | async; as result) {
@if (result.isLoading) {
<p>Mutation is loading</p>
}
@if (result.isSuccess) {
<p>Mutation was successful</p>
}
@if (result.isError) {
<p>Mutation encountered an Error</p>
}
}
`,
})
export class TodosComponent {
addTodo = inject(TodosService).addTodo();
onAddTodo({ title }) {
this.addTodo.mutate({ title });
// Or
this.addTodo.mutateAsync({ title });
}
}
If you prefer a signal based approach, then you can use the result
getter function on addTodo
.
@Component({
template: `
<input #ref />
<button (click)="onAddTodo({ title: ref.value })">Add todo</button>
@if (addTodo.result(); as result) {
@if (result.isLoading) {
<p>Mutation is loading</p>
}
@if (result.isSuccess) {
<p>Mutation was successful</p>
}
@if (result.isError) {
<p>Mutation encountered an Error</p>
}
}
`,
})
export class TodosComponent {
addTodo = inject(TodosService).addTodo();
onAddTodo({ title }) {
this.addTodo.mutate({ title });
}
}
A more in depth example can be found on our playground.
You can inject a default config for the underlying @tanstack/query
instance by using the provideQueryClientOptions({})
function.
import { provideQueryClientOptions } from '@ngneat/query';
bootstrapApplication(AppComponent, {
providers: [
provideQueryClientOptions({
defaultOptions: {
queries: {
staleTime: 3000,
},
},
}),
],
});
It accept also a function factory if you need an injection context while creating the configuration.
import { provideQueryClientOptions } from '@ngneat/query';
const withFunctionalFactory: QueryClientConfigFn = () => {
const notificationService = inject(NotificationService);
return {
queryCache: new QueryCache({
onError: (error: Error) => notificationService.notifyError(error),
}),
defaultOptions: {
queries: {
staleTime: 3000,
},
},
};
};
bootstrapApplication(AppComponent, {
providers: [provideQueryClientOptions(withFunctionalFactory)],
});
The intersectResults
function is used to merge multiple signal queries into one.
It will return a new base query result that will merge the results of all the queries.
Note: The data will only be mapped if the result is successful and otherwise just returned as is on any other state.
import { intersectResults } from '@ngneat/query';
@Component({
standalone: true,
template: `
<h1>Signals Intersection</h1>
@if (intersection(); as intersectionResult) {
@if (intersectionResult.isLoading) {
<p>Loading</p>
}
@if (intersectionResult.isSuccess) {
<p>{{ intersectionResult.data }}</p>
}
@if (intersectionResult.isError) {
<p>Error</p>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TodosPageComponent {
#todosService = inject(TodosService);
intersection = intersectResults(
[
this.#todosService.getTodo('1').result,
this.#todosService.getTodo('2').result,
],
([todoOne, todoTwo]) => todoOne.title + todoTwo.title,
);
intersectionAsObject = intersectResults(
{
todoOne: this.#todosService.getTodo('1').result,
todoTwo: this.#todosService.getTodo('2').result,
},
({ todoOne, todoTwo }) => todoOne.title + todoTwo.title,
);
}
The filterSuccessResult
operator is useful when you want to filter only successful results:
todosService.getTodos().result$.pipe(filterSuccessResult())
The filterErrorResult
operator is useful when you want to filter only error results:
todosService.getTodos().result$.pipe(filterErrorResult())
The tapSuccessResult
operator is useful when you want to run a side effect only when the result is successful:
todosService.getTodos().result$.pipe(tapSuccessResult(console.log))
The tapErrorResult
operator is useful when you want to run a side effect only when the result is erroring:
todosService.getTodos().result$.pipe(tapErrorResult(console.log))
The mapResultData
operator maps the data
property of the result
object in case of a successful result.
this.todosService.getTodos().result$.pipe(
mapResultData((data) => {
return {
todos: data.todos.filter(predicate),
};
}),
);
An operator that takes values emitted by the source observable until the isFetching
property on the result is false.
It is intended to be used in scenarios where an observable stream should be listened to until the result has finished fetching (e.g success or error).
todosService.getTodos().result$.pipe(takeUntilResultFinalize())
An operator that takes values emitted by the source observable until the isSuccess
property on the result is true.
It is intended to be used in scenarios where an observable stream should be listened to until a successful result is emitted.
todosService.getTodos().result$.pipe(takeUntilResultSuccess())
An operator that takes values emitted by the source observable until the isError
property on the result is true.
It is intended to be used in scenarios where an observable stream should be listened to until an error result is emitted.
todosService.getTodos().result$.pipe(takeUntilResultError())
Starts the observable stream with a pending query result that would also be returned upon creating a normal query:
this.todosService.getTodos().result$.pipe(
filterSuccess(),
switchMap(() => someSource),
startWithPendingQueryResult(),
);
The intersectResults$
operator is used to merge multiple observable queries into one, this is usually done with a combineLatest
.
It will return a new base query result that will merge the results of all the queries.
Note: The data will only be mapped if the result is successful and otherwise just returned as is on any other state.
const query = combineLatest({
todos: todos.result$,
posts: posts.result$,
}).pipe(
intersectResults$(({ todos, posts }) => { ... })
)
const query = combineLatest([todos.result$, posts.result$]).pipe(
intersectResults$(([todos, posts]) => { ... })
)
createSuccessObserverResult
- Create success observer result:
import { createSyncObserverResult } from '@ngneat/query';
result = of(createSuccessObserverResult(data))
-
createPendingObserverResult
- Create pending observer result -
updateOptions
- In cases that you want to use the same observer result and update the options you can use theupdateOptions
method:
@Component({
standalone: true,
imports: [RouterModule],
template: `
<a [queryParams]="{ id: 2 }">User 2</a>
@if (user().isLoading) {
<span class="loader"></span>
}
@if (user().data; as user) {
{{ user.email }}
}
`,
})
export class UsersPageComponent {
usersService = inject(UsersService);
userResultDef = this.usersService.getUser(
+inject(ActivatedRoute).snapshot.queryParams['id'],
);
user = this.userResultDef.result;
@Input()
set id(userId: string) {
this.userResultDef.updateOptions(this.usersService.getUserOptions(+userId));
}
}
ObservableQueryResult
- Alias forObservable<QueryObserverResult<Data, Error>>
SignalQueryResult
- Alias forSignal<QueryObserverResult<Data, Error>>
injectIsFetching
is a function that returns the number of the queries that your application is loading or fetching in the background (useful for app-wide loading indicators).
import { injectIsFetching } from '@ngneat/query';
class TodoComponent {
#isFetching = injectIsFetching();
// How many queries overall are currently fetching data?
public isFetching$ = this.#isFetching().result$;
// How many queries matching the todos prefix are currently fetching?
public isFetchingTodos$ = this.#isFetching({ queryKey: ['todos'] }).result$;
}
import { injectIsFetching } from '@ngneat/query';
class TodoComponent {
#isFetching = injectIsFetching();
// How many queries overall are currently fetching data?
public isFetching = this.#isFetching().result;
// How many queries matching the todos prefix are currently fetching?
public isFetchingTodos = this.#isFetching({
queryKey: ['todos'],
}).result;
}
injectIsMutating
is an optional hook that returns the number of mutations that your application is fetching (useful for app-wide loading indicators).
import { injectIsMutating } from '@ngneat/query';
class TodoComponent {
#isMutating = injectIsMutating();
// How many queries overall are currently fetching data?
public isFetching$ = this.#isMutating().result$;
// How many queries matching the todos prefix are currently fetching?
public isFetchingTodos$ = this.#isMutating({ queryKey: ['todos'] }).result$;
}
import { injectIsMutating } from '@ngneat/query';
class TodoComponent {
#isMutating = injectIsMutating();
// How many queries overall are currently fetching data?
public isFetching = this.#isMutating().result;
// How many queries matching the todos prefix are currently fetching?
public isFetchingTodos = this.#isMutating({
queryKey: ['todos'],
}).result;
}
Install the @ngneat/query-devtools
package. Lazy load and use it only in development
environment:
import { provideQueryDevTools } from '@ngneat/query-devtools';
import { environment } from 'src/environments/environment';
bootstrapApplication(AppComponent, {
providers: [environment.production ? [] : provideQueryDevTools(options)],
});
See all the avilable options here.
On the Server:
import { provideQueryClient } from '@ngneat/query';
import { QueryClient, dehydrate } from '@tanstack/query-core';
import { renderApplication } from '@angular/platform-server';
async function handleRequest(req, res) {
const queryClient = new QueryClient();
let html = await renderApplication(AppComponent, {
providers: [provideQueryClient(queryClient)],
});
const queryState = JSON.stringify(dehydrate(queryClient));
html = html.replace(
'</body>',
`<script>window.__QUERY_STATE__ = ${queryState}</script></body>`,
);
res.send(html);
queryClient.clear();
}
Client:
import { importProvidersFrom } from '@angular/core';
import { bootstrapApplication, BrowserModule } from '@angular/platform-browser';
import { provideQueryClient } from '@ngneat/query';
import { QueryClient, hydrate } from '@tanstack/query-core';
const queryClient = new QueryClient();
const dehydratedState = JSON.parse(window.__QUERY_STATE__);
hydrate(queryClient, dehydratedState);
bootstrapApplication(AppComponent, {
providers: [
importProvidersFrom(
BrowserModule.withServerTransition({ appId: 'server-app' }),
),
provideQueryClient(queryClient),
],
});
The queryFn
run inside an injection context so we can do the following if we want:
import { injectQuery } from '@ngneat/query';
export function getTodos() {
const query = injectQuery();
return query({
queryKey: ['todos'] as const,
queryFn: () => {
return inject(HttpClient).get<Todo[]>(
'https://jsonplaceholder.typicode.com/todos',
);
},
});
}
We can also pass a custom injector:
export function getTodos({ injector }: { injector: Injector }) {
const query = injectQuery({ injector });
return query({
queryKey: ['todos'] as const,
injector,
queryFn: ({ signal }) => {
return inject(HttpClient).get<Todo[]>(
'https://jsonplaceholder.typicode.com/todos',
);
},
});
}
Netanel Basal |
Philipp Czarnetzki |
Thank goes to all these wonderful people who contributed ❤️