Skip to content

Latest commit

 

History

History
730 lines (557 loc) · 19.7 KB

README.md

File metadata and controls

730 lines (557 loc) · 19.7 KB

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.

Features

✅  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


@ngneat/query commitizen PRs coc-badge semantic-release styled with prettier

Motivation

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.

Installation

npm i @ngneat/query

Stackblitz Example

Please be aware that the @tanstack/query-core package must also be installed for the functionality to operate correctly.

Query Client

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

Query

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.

Component Usage - Observable

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();
}

Component Usage - Signal

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;
}

Typing Query Options

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;
  }
}

Infinite Query

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

Mutation

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.

Query Global Options

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)],
});

Signal Utils

intersectResults

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,
  );
}

RxJS Operators

filterSuccessResult

The filterSuccessResult operator is useful when you want to filter only successful results:

todosService.getTodos().result$.pipe(filterSuccessResult())

filterErrorResult

The filterErrorResult operator is useful when you want to filter only error results:

todosService.getTodos().result$.pipe(filterErrorResult())

tapSuccessResult

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))

tapErrorResult

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))

mapResultData

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),
    };
  }),
);

takeUntilResultFinalize

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())

takeUntilResultSuccess

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())

takeUntilResultError()

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())

startWithPendingQueryResult

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(),
);

intersectResults$

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]) => { ... })
)

Utils

  • 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 the updateOptions 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));
  }
}

Type Utils

  • ObservableQueryResult - Alias for Observable<QueryObserverResult<Data, Error>>
  • SignalQueryResult - Alias for Signal<QueryObserverResult<Data, Error>>

Is Fetching

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).

Observable Example

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$;
}

Signal Example

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;
}

Is Mutating

injectIsMutating is an optional hook that returns the number of mutations that your application is fetching (useful for app-wide loading indicators).

Observable Example

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$;
}

Signal Example

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;
}

Devtools

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.

SSR

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),
  ],
});

Injection Context

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',
      );
    },
  });
}

Created By

Netanel Basal
Netanel Basal

Contributors ✨

Netanel Basal
Philipp Czarnetzki

Thank goes to all these wonderful people who contributed ❤️