TanStack Query: caching, retries en optimistic UI zonder RxJS-boilerplate

Krachtig asynchroon state-management voor TS/JS, React, Solid, Vue, Svelte én Angular.

TL;DR – Vergeet handmatige isLoading‑booleans en retries; TanStack Query neemt fetch‑logica, caching, retries, optimistic updates en meer uit handen zodat jij je kunt focussen op features in plaats van plumbing.
Coffee‑break coding, here we come. ☕🚀

Inhoudsopgave

Waarom nog een data‑library?

De meeste Angular‑projecten starten met een simpel recept: injecteer HttpClient, maak een RxJS‑stream, toggle een paar flags in je component en klaar. Het werkt prima tot het moment dat je applicatie tientallen calls heeft, op meerdere plekken dezelfde data nodig is en gebruikers mobiel of met haperende wifi werken. Vanaf dat punt worden je loading‑booleans moeilijk beheersbaar, verschijnen spinners op het scherm en ben je kostbare tijd kwijt aan het schrijven van steeds dezelfde boilerplate.

TanStack Query positioneert zichzelf als data state management bóven je HTTP‑laag. Het is niet nog een nieuwe state‑management tool, maar een andere manier van werken met externe data waardoor een dedicated state-management package in sommige gevallen overbodig is. Het combineert het gemak van React Query (waar het van afstamt) met Angular‑friendly signals, zodat je automatisch reactive updates krijgt zonder dat je iets hoeft te subscribe‑en of unsubscribe‑en.

Framework‑agnostisch & migratie‑vriendelijk

Hoewel deze blog focust op de Angular‑adapter, is de kern van TanStack Query een framework‑onafhankelijke TypeScript‑bibliotheek. Er bestaan first‑party wrappers voor veel populaire front‑end ecosystemen:

FrameworkPackageStatus
React@tanstack/react-queryProduction‑ready
Solid@tanstack/solid-queryProduction‑ready
Svelte@tanstack/svelte-queryProduction‑ready
Vue@tanstack/vue-queryProduction‑ready
Angular@tanstack/angular-query-experimentalExperimental (maar al heel bruikbaar)

Wat betekent dit in de praktijk?

  • Multi‑repo teams kunnen dezelfde data‑mindset delen, ongeacht of er nu React‑, Vue‑ of Angular‑apps in de mono‑repo zitten. De API‑vorm lijkt per framework sterk op elkaar, dus documentatie en code‑reviews worden een stuk eenvoudiger.
  • Migreren zonder herschrijven — stap je van React naar Angular (of andersom) dan kan 80‑90 % van je query‑ en mutation‑code intact blijven. Alleen de binding naar de UI‑layer verandert.
  • Micro‑frontends: elk deel van je pagina, onafhankelijk van het gekozen framework, kan dezelfde cache delen mits je een gedeelde QueryClient expose‑t. Daarmee voorkom je dubbel werk en dubbele netwerk‑requests.

Kortom: TanStack Query bepaalt niet je tech‑stack. Het is een robuuste data‑laag die meegroeit en meeverhuist, of je nu morgen Solid JS probeert of volgend jaar alles omschrijft naar Svelte.

De naïeve aanpak

Onderstaande code is herkenbaar voor elke Angular‑ontwikkelaar. We simuleren een kleine netwerkvertraging zodat je het loading‑gedrag kunt zien, vangen een mogelijke error, kopiëren de response naar component‑state en onthouden netjes of we nog bezig zijn.

				
					private http = inject(HttpClient);
isLoading = false;
isError = false;
error: any = null;
users: any[] = [];

constructor() {
  this.loadUsers().subscribe(users => {
    this.users = users;
    this.isLoading = false;
  });
}

loadUsers(): Observable<any> {
  this.isLoading = true;
  return timer(1000).pipe(
    switchMap(() => this.http.get('https://reqres.in/api/users?page=1').pipe(
      catchError(error => {
        this.isLoading = false;
        this.isError = true;
        this.error = error;
        return of({ data: [] });
      })
    )),
    map((response: any) => response.data)
  );
}
				
			

Dit werkt, maar het schaalt niet. Elke nieuwe endpoint betekent kopiëren‑plakken van flags, handlers en catchError‑blokken. De éne keer vergeet je isLoading terug te zetten, de andere keer vergeet je de subscription op te ruimen. En als dezelfde call op twee plekken in de UI nodig is, voert de app hem net zo vaak uit—géén caching en dus onnodig netwerkverkeer.

De stap naar TanStack Query

Installeren & configureren

				
					npm i @tanstack/angular-query-experimental
				
			

Voeg in je bootstrap één regel toe:

				
					import { QueryClient, provideTanStackQuery } from '@tanstack/angular-query-experimental';

bootstrapApplication(AppComponent, {
  providers: [
    provideTanStackQuery(new QueryClient()), // optional: .withDevtools()
  ],
});
				
			

Hiermee registreer je een singleton QueryClient in Angulars DI‑container. Vanaf dit moment kun je overal in je component­tree queries en mutations injecteren — zonder dat je een service hoeft te genereren.

Bestaande RxJS‑functie hergebruiken

Je bestaande loadUsers() kan blijven bestaan. De boolean flags kunnen uiteraard weg en vergeet niet de catchError te verwijderen zodat TanStack Query de error kan afvangen en verwerken. We hoeven nu de loadUsers() alleen in een query te wrappen:

				
					import { injectQuery } from '@tanstack/angular-query-experimental';

const usersQuery = injectQuery(() => ({
  queryKey: ['users'],
  queryFn: () => lastValueFrom(this.loadUsers()),
}));
				
			

Je template kan nu simpelweg luisteren naar usersQuery.isLoading(), usersQuery.isError() en usersQuery.data()—alles signals, dus elke state‑change triggert automatisch een Angular change‑detection cycle.

Caching & background‑refetch

Het probleem

Stel: je hebt een product­catalogus die op zowel de home‑pagina als in een sidebar getoond wordt. In de naïeve aanpak voert ieder component z’n eigen HttpClient.get() uit. Resultaat: dubbele requests, verspilde milliseconden én een UI die telkens tussen loading en loaded flikkert wanneer je navigeert.

De TanStack‑oplossing

TanStack Query bewaart responses in een LRU‑cache. Met een staleTime geef je aan hoe lang de data vers blijft. Zolang de data niet stale is, levert TanStack Query de cached version onmiddellijk terug, waardoor je component direct wat te tonen heeft. Pas wanneer de gebruiker terugkomt naar de tab of de staleTime verlopen is, wordt in de achtergrond een stille refetch gestart. De UI blijft gevuld; als er nieuwe data komt, verschijnt die zonder vertonen van spinners.

Implementatie

				
					productsQuery = injectQuery(() => ({
  queryKey: ['products'],
  queryFn: () => this.http.get<Product[]>('/api/products'),
  staleTime: 10 * 60 * 1000, // 10 minuten
}));
				
			

Wat merkt de eindgebruiker?

Gebruikers zien vrijwel geen loading‑states meer wanneer zij door de applicatie klikken, en ze krijgen tóch actuele data zodra die beschikbaar is. Tegelijk verbruik je minder bandbreedte omdat verouderde gegevens niet onnodig opnieuw opgehaald worden.

Refetching

Als de data stale is (standaard direct; staleTime = 0) wordt automatisch een background-refetch gedaan wanneer het component opnieuw geladen wordt of als de browser pagina verborgen is geweest en weer zichtbaar wordt. Tijdens een refetch blijft de isLoading signal op false, maar wordt de isFetching signal op true gezet. Zo is het mogelijk een fetching-indicator te tonen, bijvoorbeeld een veelgebruikte dunne progress-bar-lijn bovenin beeld.

Automatische retries met exponential back‑off

Het probleem

Mobiele netwerken, hotel‑wifi en zelfs bedrijfs‑proxy’s laten je weleens in de steek. Zonder retries krijgt de gebruiker direct een foutmelding en moet hij handmatig refreshen. Met eigen RxJS‑retry‑logic eindig je met een hoop retryWhen, delay, scan en takeWhile. Naast lastig te testen is dat ook foutgevoelig — één dom foutje en je DDoS‑t de server per ongeluk.

Hoe TanStack Query het oplost

Retries zijn standaard ingeschakeld (max drie pogingen), met een exponentiële back‑off van 0 s, 0,5 s, 1 s, 2 s… Je kunt dit global of per query aanpassen. Cruciaal: network errors leiden tot retries, maar business errors (HTTP 400/403 etc.) niet, zodat je geen eindeloze loops krijgt.

Implementatievoorbeeld

Standaard hoef je niks te doen en werken retries automatisch. Je kunt echter het aantal retries (bijv: retry: 6) en de back-off vertraging (retryDelay) eenvoudig aanpassen. Eventueel kan je ook de retry logica zelf schrijven:

				
					ordersQuery = injectQuery(() => ({
  queryKey: ['orders'],
  queryFn: () => this.http.get('/api/orders'),
  retry: (failureCount, error) => {
    // alleen retries bij offline‑ of 5xx‑situaties
    if (error.status >= 500 || error.status === 0) return failureCount < 5;
    return false;
  },
  retryDelay: attempt => Math.min(500 * 2 ** attempt, 30_000),
}));
				
			

Resultaat

Zonder extra code maak je de applicatie robuust tegen tijdelijke netwerkproblemen. Gebruikers merken dat kleine haperingen automatisch worden opgevangen en hun actie uiteindelijk toch slaagt zonder frustrerende ‘Probeer opnieuw’ pop‑ups.

Mutations, optimistic UI & cache‑invalidatie

Het probleem

Wanneer een gebruiker z’n profiel wijzigt, wil je á la minute een visuele bevestiging tonen. Tegelijk wil je niet wachten op de serverrespons, maar wel kunnen terugrollen als het misgaat. En daarna moeten alle plaatsen waar dat profiel getoond wordt opnieuw de juiste gegevens presenteren. Handmatig betekent dat: tijdelijke state opslaan, rollback‑logica schrijven, en afhankelijk van je architectuur events broadcasten of stores updaten.

TanStack‑magic

Een mutation is in TanStack Query de tegenhanger van een query. Binnen onMutate kun je de cache alvast manipuleren — optimistic update. Slaat de request fout, dan krijg je in onError de oude cache als context om automatisch te herstellen. In onSuccess kun je simpelweg invalidateQueries oproepen zodat de verse data met één regel overal ververst wordt.

Implementatie

				
					const updateUser = injectMutation(({ queryClient }) => ({
  mutationFn: (dto: UserDto) => this.http.put<User>(`/api/users/${dto.id}`, dto),

  onMutate: async (dto) => {
    // 1. Snapshot vorige staat
    const previous = queryClient.getQueryData<User[]>(['users']);

    // 2. Optimistic cache‑update
    queryClient.setQueryData(['users'], users =>
      users?.map(u => u.id === dto.id ? { ...u, ...dto } : u)
    );

    return { previous };  // context voor rollback
  },

  onError: (_error, _dto, ctx) => {
    // 3. Herstel oude staat
    queryClient.setQueryData(['users'], ctx?.previous);
  },

  onSuccess: () => {
    // 4. Ververs gerelateerde queries
    queryClient.invalidateQueries({ queryKey: ['users'] });
  },
}));
				
			

Resultaat

Gebruikers zien hun wijziging onmiddellijk, zelfs op een trage verbinding. Als de server toch een fout terugstuurt, valt de UI soepel terug naar de vorige waarde — zónder dat de gebruiker iets hoeft te doen. Dit verhoogt de perceived performance aanzienlijk.

Paginated & infinite queries

Het probleem

Paginering met pure RxJS betekent state voor de huidige pagina, operators om de juiste call te doen, en logica om te voorkomen dat het scherm even leeg wordt bij elke overgang. Infinite scroll voegt daar extra complexiteit aan toe: je moet page‑parameters onthouden en progressieve arrays samenvoegen.

Oplossing

  • keepPreviousData houdt de oude pagina in de cache actief terwijl de nieuwe binnenkomt – de gebruiker ziet dus nooit een lege lijst (of loader).
  • injectInfiniteQuery abstraheert weg wat een volgende pageParam moet zijn en levert je paginated data in één vlakke array of in pages‑vorm, afhankelijk van je voorkeur.

Implementatie klassieke paginering

				
					const currentPage = signal(1);
const pagedUsers = injectQuery(() => ({
  queryKey: ['users', currentPage()],
  queryFn: () => this.http.get(`/api/users?page=${currentPage()}`)
                         .pipe(map((r:any) => r.data)),
  keepPreviousData: true,
}));

// event handlers
function next() { currentPage.update(p => p + 1); }
function prev() { currentPage.update(p => Math.max(1, p - 1)); }
				
			

Implementatie infinite scroll

				
					const infiniteUsers = injectInfiniteQuery(() => ({
  queryKey: ['users-infinite'],
  queryFn: ({ pageParam = 1 }) => this.http
    .get(`/api/users?page=${pageParam}`)
    .pipe(map((r:any) => r.data)),
  getNextPageParam: (_lastPage, pages) => pages.length + 1,
}));
				
			

Met infiniteUsers.fetchNextPage() laad je meer content in terwijl de gebruiker naar beneden scrolt, en infiniteUsers.hasNextPage() vertelt of er nog iets te halen valt.

Resultaat

Naadloze paginering zonder flitsende blanco schermen en zonder het complexe combineLatest van RxJS. Infinite scroll voelt alsof je een native app gebruikt, zonder merkbare vertraging tussen batches.

Initial & placeholder data

Het probleem

Skeleton‑screens zorgen voor een rustige gebruikerservaring, maar vaak heb je dubbele types nodig: één voor echte data en één voor placeholder data. Dat leidt tot if (isSkeleton) checks in je componenten en maakt unit‑tests zwaarder.

TanStack‑aanpak

Met placeholderData kun je een functie geven die onmiddellijk een shape‑identieke stub retourneert. Zodra de echte fetch klaar is, wordt de placeholder transparant vervangen. Als je data al in localStorage of via server‑side rendering beschikbaar is, kun je in plaats daarvan initialData gebruiken zodat er überhaupt geen eerste fetch meer nodig is.

Voorbeeld met skeletons

				
					usersQuery = injectQuery(() => ({
  queryKey: ['users'],
  queryFn: () => lastValueFrom(this.loadUsers()),
  placeholderData: () => Array.from({ length: 6 }, (_, i) => ({
    id: `skeleton-${i}`,
    first_name: '—', last_name: '', email: '', avatar: '',
  })),
  initialDataUpdatedAt: Date.now(),
}));
				
			

In je template kun je eenvoudig detecteren of het item een skeleton is (bv. id.startsWith(‘skeleton’)) en conditioneel CSS toepassen. Zodra de echte data arriveert, verdwijnen de placeholders zonder extra flags of events.

Resultaat

  • No‑layout‑shift: je ontwerp springt niet.
  • Geen dubbele types: skeleton en echte data delen exact hetzelfde contract.
  • Minder code: één bron van waarheid.

Conclusie

TanStack Query vervangt tientallen regels herhaalde RxJS‑plumbing door declaratieve, goed gedocumenteerde helpers. Je krijgt caching, retries, background updates, optimistic UI en devtools in de browser praktisch gratis. De tijd die je anders aan infrastructuur kwijt bent, kun je nu besteden aan features.

Ready voor minder spinnerstress? 👍 Probeer TanStack Query in één van je bestaande componenten, kijk naar de devtools‑overlay en zie hoe je netwerkverkeer en UI‑flicker afnemen. Eenmaal geproefd, wil je niet meer terug.

Hoe meer je gedeelde state en caching toepast hoe eerder je tegen complexere vraagstukken aanloopt zoals lastige cache invalidation, out-of-sync state met meerdere clients etc.

Om deze en andere vraagstukken uit te leggen en een oplossing te bieden komt binnenkort een deel 2 van mijn TanStack Query blog.

Meer weten over werken bij Cloud Republic?

Bij Cloud Republic werken echte teamplayers. We zijn vastberaden om, samen met elkaar, steengoede oplossingen te ontwikkelen. We zijn trots op onze cultuur die persoonlijke en professionele groei stimuleert door samenwerking en creativiteit en kunnen niet wachten om jou hiermee kennis te laten maken.

Altijd de laatste trends en development nieuws in je inbox?

Schrijf je in voor onze .NET updates. Hier delen we de onze blogs, cases en tips over de nieuwste tech. Zo weet je zeker dat altijd op de hoogte blijft van de laatste trends in software development.