Skip to main content
This guide demonstrates how to implement infinite scroll and “load more” patterns using infinite queries, which cache multiple pages within a single cache entry.

Infinite Query Pattern

Use build.infiniteQuery() to define an endpoint that supports pagination:
app/pokemon/api.ts
import { createApi, fetchBaseQuery } from 'ngrx-rtk-query';

type Pokemon = { id: string; name: string };

export const pokemonApi = createApi({
  reducerPath: 'pokemonApi',
  baseQuery: fetchBaseQuery({ baseUrl: 'https://example.com/pokemon' }),
  endpoints: (build) => ({
    getPokemon: build.infiniteQuery<Pokemon[], string, number>({
      infiniteQueryOptions: {
        initialPageParam: 1,
        getNextPageParam: (lastPage, allPages, lastPageParam) => 
          lastPageParam + 1,
        getPreviousPageParam: (firstPage, allPages, firstPageParam) =>
          firstPageParam > 1 ? firstPageParam - 1 : undefined,
      },
      query: ({ queryArg, pageParam }) => `/type/${queryArg}?page=${pageParam}`,
    }),
  }),
});

export const { useGetPokemonInfiniteQuery } = pokemonApi;
Infinite queries cache all pages together, making them ideal for “load more” buttons and infinite scroll. The data field contains a { pages, pageParams } structure.

Load More Button

Implement a “Load More” button that appends new results:
app/pokemon/pokemon-list.component.ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { useGetPokemonInfiniteQuery } from './api';

@Component({
  selector: 'app-pokemon-list',
  standalone: true,
  template: `
    <section>
      <h1>Pokemon - {{ pokemonType }}</h1>
      
      @if (pokemonQuery.isLoading()) {
        <p>Loading...</p>
      }
      
      <ul>
        @for (pokemon of allPokemon(); track pokemon.id) {
          <li>{{ pokemon.name }}</li>
        }
      </ul>
      
      @if (pokemonQuery.hasNextPage()) {
        <button
          [disabled]="pokemonQuery.isFetchingNextPage()"
          (click)="loadMore()"
        >
          {{ pokemonQuery.isFetchingNextPage() ? 'Loading...' : 'Load More' }}
        </button>
      }
      
      @if (!pokemonQuery.hasNextPage() && !pokemonQuery.isLoading()) {
        <p>No more pokemon to load</p>
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PokemonListComponent {
  readonly pokemonType = 'fire';
  readonly pokemonQuery = useGetPokemonInfiniteQuery(this.pokemonType);

  // Flatten all pages into a single array
  readonly allPokemon = computed(() => 
    this.pokemonQuery.data()?.pages.flat() ?? []
  );

  loadMore(): void {
    this.pokemonQuery.fetchNextPage();
  }
}

Infinite Scroll with Intersection Observer

Automatically load more items when scrolling to the bottom:
app/posts/infinite-posts.component.ts
import {
  ChangeDetectionStrategy,
  Component,
  computed,
  effect,
  ElementRef,
  signal,
  viewChild,
} from '@angular/core';
import { useGetPostsInfiniteQuery } from './api';

@Component({
  selector: 'app-infinite-posts',
  standalone: true,
  template: `
    <section>
      <h1>All Posts</h1>
      
      <ul>
        @for (post of allPosts(); track post.id) {
          <li>
            <h3>{{ post.name }}</h3>
            <p>{{ post.content }}</p>
          </li>
        }
      </ul>
      
      <div #sentinel class="sentinel"></div>
      
      @if (postsQuery.isFetchingNextPage()) {
        <div class="loading-spinner">Loading more posts...</div>
      }
      
      @if (!postsQuery.hasNextPage() && !postsQuery.isLoading()) {
        <p class="end-message">You've reached the end!</p>
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InfinitePostsComponent {
  readonly postsQuery = useGetPostsInfiniteQuery(undefined);
  readonly sentinel = viewChild<ElementRef>('sentinel');

  readonly allPosts = computed(() => 
    this.postsQuery.data()?.pages.flat() ?? []
  );

  constructor() {
    // Set up intersection observer for infinite scroll
    effect((onCleanup) => {
      const element = this.sentinel()?.nativeElement;
      if (!element) return;

      const observer = new IntersectionObserver(
        (entries) => {
          const [entry] = entries;
          if (
            entry.isIntersecting &&
            this.postsQuery.hasNextPage() &&
            !this.postsQuery.isFetchingNextPage()
          ) {
            this.postsQuery.fetchNextPage();
          }
        },
        { rootMargin: '100px' } // Start loading 100px before reaching bottom
      );

      observer.observe(element);
      onCleanup(() => observer.disconnect());
    });
  }
}
Set rootMargin: '100px' on the IntersectionObserver to start loading the next page slightly before the user reaches the bottom, creating a seamless experience.

API Definition for Infinite Scroll

app/posts/api.ts
import { createApi, fetchBaseQuery } from 'ngrx-rtk-query';

interface Post {
  id: number;
  name: string;
  content: string;
}

interface PostsResponse {
  posts: Post[];
  nextPage?: number;
}

export const postsApi = createApi({
  baseQuery: fetchBaseQuery({ baseUrl: 'http://api.localhost.com' }),
  tagTypes: ['Posts'],
  endpoints: (build) => ({
    getPosts: build.infiniteQuery<PostsResponse, void, number>({
      infiniteQueryOptions: {
        initialPageParam: 1,
        getNextPageParam: (lastPage) => lastPage.nextPage,
        getPreviousPageParam: (firstPage, allPages, firstPageParam) =>
          firstPageParam > 1 ? firstPageParam - 1 : undefined,
      },
      query: ({ pageParam }) => ({
        url: '/posts',
        params: { page: pageParam, limit: 20 },
      }),
      providesTags: (result) =>
        result
          ? [
              ...result.pages.flatMap((page) => 
                page.posts.map(({ id }) => ({ type: 'Posts', id }) as const)
              ),
              { type: 'Posts', id: 'INFINITE-LIST' },
            ]
          : [{ type: 'Posts', id: 'INFINITE-LIST' }],
    }),
  }),
});

export const { useGetPostsInfiniteQuery } = postsApi;

Bidirectional Infinite Scroll

Load content in both directions:
app/posts/bidirectional-scroll.component.ts
import { ChangeDetectionStrategy, Component, computed } from '@angular/core';
import { useGetPostsInfiniteQuery } from './api';

@Component({
  selector: 'app-bidirectional-scroll',
  standalone: true,
  template: `
    <section>
      @if (postsQuery.hasPreviousPage()) {
        <button
          [disabled]="postsQuery.isFetchingPreviousPage()"
          (click)="loadPrevious()"
        >
          {{ postsQuery.isFetchingPreviousPage() ? 'Loading...' : 'Load Previous' }}
        </button>
      }
      
      <ul>
        @for (post of allPosts(); track post.id) {
          <li>{{ post.name }}</li>
        }
      </ul>
      
      @if (postsQuery.hasNextPage()) {
        <button
          [disabled]="postsQuery.isFetchingNextPage()"
          (click)="loadNext()"
        >
          {{ postsQuery.isFetchingNextPage() ? 'Loading...' : 'Load Next' }}
        </button>
      }
    </section>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BidirectionalScrollComponent {
  readonly postsQuery = useGetPostsInfiniteQuery(undefined, {
    initialPageParam: 5, // Start from middle page
  });

  readonly allPosts = computed(() => 
    this.postsQuery.data()?.pages.flatMap((page) => page.posts) ?? []
  );

  loadNext(): void {
    this.postsQuery.fetchNextPage();
  }

  loadPrevious(): void {
    this.postsQuery.fetchPreviousPage();
  }
}

Refetching All Pages

Control whether to refetch all pages or just the first:
export class InfinitePostsComponent {
  readonly postsQuery = useGetPostsInfiniteQuery(undefined, {
    // When true (default), refetches all cached pages on invalidation
    refetchCachedPages: true,
  });

  // Manually refetch all pages
  refreshAll(): void {
    this.postsQuery.refetch({ refetchCachedPages: true });
  }

  // Refetch only first page
  refreshFirst(): void {
    this.postsQuery.refetch({ refetchCachedPages: false });
  }
}
When refetchCachedPages: true, tag invalidation will sequentially refetch all pages currently in the cache. Set to false to only refetch the first page.

Data Structure

Infinite queries return a special data structure:
interface InfiniteData<TData, TPageParam> {
  pages: TData[];        // Array of page responses
  pageParams: TPageParam[]; // Array of page parameters used
}

// Example:
const data = pokemonQuery.data();
// {
//   pages: [
//     [{ id: '1', name: 'Charmander' }, ...],  // Page 1
//     [{ id: '11', name: 'Metapod' }, ...],    // Page 2
//   ],
//   pageParams: [1, 2]
// }

Query State Properties

Infinite queries provide additional state:
const query = useGetPostsInfiniteQuery(undefined);

// Standard query states
query.isLoading()      // First page loading
query.isFetching()     // Any page loading
query.isError()        // Query error
query.data()           // InfiniteData structure

// Infinite query specific
query.hasNextPage()           // More pages available
query.hasPreviousPage()       // Previous pages available
query.isFetchingNextPage()    // Loading next page
query.isFetchingPreviousPage() // Loading previous page

// Methods
query.fetchNextPage()     // Load next page
query.fetchPreviousPage() // Load previous page
query.refetch({ refetchCachedPages: true }) // Refetch pages

Comparison with Lazy Queries

// All pages cached together
const query = useGetPostsInfiniteQuery(undefined);
const allPosts = computed(() => query.data()?.pages.flat() ?? []);
query.fetchNextPage();

// ✅ Best for: Infinite scroll, load more
// ✅ All pages in one cache entry
// ✅ Built-in pagination methods

Best Practices

Performance

  • Use computed() to flatten pages once rather than in the template
  • Implement virtual scrolling for very large lists (e.g., with @angular/cdk/scrolling)
  • Set appropriate rootMargin on IntersectionObserver

User Experience

  • Show loading states for isFetchingNextPage()
  • Display “end of list” message when !hasNextPage()
  • Consider skeleton loaders for initial load
  • Preserve scroll position when navigating away and back

Cache Management

// ✅ Use specific tags for infinite queries
providesTags: [{ type: 'Posts', id: 'INFINITE-LIST' }]

// ✅ Invalidate to refetch all pages
invalidatesTags: [{ type: 'Posts', id: 'INFINITE-LIST' }]

// ⚠️ Consider cache size for very long lists
// Use pagination for better memory management
For traditional page-by-page navigation, use Pagination with lazy queries instead of infinite queries.

Build docs developers (and LLMs) love