~ projects
blog
cv
cd ~/blog/tanstack-query
scp visitor@blog.jjungbluth.de:~/blog/tanstack-query.pdf/ ./
firefox tanstack-query.pdf
Hero Image
React
Svelte
TanStack Query
SvelteKit
Remote Functions

Meine Erfahrung mit TanStack Query

Von React über Svelte 4 zu Svelte 5 Remote Functions – wie ich mit Remote-Datenverwaltung umgegangen bin und warum ich TanStack Query hinter mir lasse.


Inhaltsverzeichnis

  1. Was ist TanStack Query?
  2. TanStack Query in React
  3. Migration zu Svelte
  4. Das Svelte-5-Problem
  5. Remote Functions als Alternative
  6. Vergleich: Mutation und Invalidierung
  7. Fazit

Was ist TanStack Query?

Wer regelmäßig mit Remote-Daten arbeitet, kennt das Problem: Daten müssen geladen, gecacht, bei Bedarf neu abgerufen und bei Mutationen invalidiert werden. Ohne eine dedizierte Lösung endet man schnell in einem Chaos aus loading-Flags, useEffect-Hooks oder $derived-Runen und manuell verwaltetem State.

TanStack Query (früher React Query) löst genau diese Probleme. Es bietet einen strukturierten Ansatz für das Arbeiten mit asynchronen Daten – inklusive automatischem Caching, Hintergrund-Refetching, Fehlerbehandlung und Cache-Invalidierung nach Mutationen.

TanStack Query in React

Ich habe angefangen TanStack Query zu nutzen, als ich noch hauptsächlich mit React gearbeitet habe. Das Kernkonzept sind useQuery-Hooks für Lesezugriffe und useMutation-Hooks für Schreiboperationen. Diese kapseln die gesamte Logik der asynchronen Datenverarbeitung ab.

// fetch data
const { data: posts, isLoading } = useQuery({
  queryKey: ['posts'],
  queryFn: () => fetch('/api/posts').then(res => res.json()),
});

// mutation (update data) with automatic cache invalidation
const queryClient = useQueryClient();

const createPost = useMutation({
  mutationFn: (newPost) =>
    fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost),
    }).then(res => res.json()),
  onSuccess: () => {
    queryClient.invalidateQueries({ queryKey: ['posts'] });
  },
});

Was mich sofort überzeugte: Die queryKey-basierte Verwaltung der Requests. Nach einer erfolgreichen Mutation kann ['posts'] als veraltet markiert werden, und alle Komponenten, die diese Daten abonniert haben, laden automatisch neu – ohne dass ich manuell State verwalten muss. Alternativ ist es auch möglich die aktualisierten Daten direkt in das Query zu schreiben, was den Round-Trip zur API sparen kann.

const createPost = useMutation({
  mutationFn: (newPost) =>
    fetch('/api/posts', {
      method: 'POST',
      body: JSON.stringify(newPost),
    }).then(res => res.json()),
  onSuccess: (createdPost) => {
    // write the server response directly into the cache
    queryClient.setQueryData(['posts'], (old) => [...old, createdPost]);
  },
});

Migration zu Svelte

Als ich den Job gewechselt habe und hauptsächlich mit Svelte gearbeitet habe, habe ich nach einer vergleichbaren Lösung gesucht. Das Projekt, an dem ich gearbeitet habe, hat viele clientseitige Interaktionen gehabt – die User können viele Daten inkrementell ändern, weswegen Form-Submissions und Full-Page-Reloads ungeeignet waren. Genau dafür ist TanStack Query gemacht, und mit @tanstack/svelte-query gibt es einen offiziellen Wrapper, der alle Features unterstützt.

Die API ist an Svelte angepasst und nutzt reaktive Stores:

<script lang="ts">
  import { createQuery, createMutation, useQueryClient } from '@tanstack/svelte-query';

  const posts = createQuery({
    queryKey: ['posts'],
    queryFn: () => fetch('/api/posts').then(res => res.json()),
  });

  const queryClient = useQueryClient();

  const createPost = createMutation({
    mutationFn: (newPost) =>
      fetch('/api/posts', {
        method: 'POST',
        body: JSON.stringify(newPost),
      }).then(res => res.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
</script>

{#if $posts.isLoading}
  <p>Lädt...</p>
{:else if $posts.data}
  {#each $posts.data as post}
    <article>{post.title}</article>
  {/each}
{/if}

Das Svelte-5-Problem

Mit Svelte 5 hat sich das Reaktivitätsmodell grundlegend geändert. Stores sind nicht entfernt worden, aber zugunsten des neuen Runensystems ($state, $derived, $effect) in den Hintergrund getreten. Das ist ein Problem für @tanstack/svelte-query gewesen, dessen API auf Svelte-Stores aufbaut.

Die Migration des Wrappers auf die Svelte-5-API hat sehr lange gedauert – verständlich, wenn man bedenkt, dass die Community hinter dem Svelte-Wrapper deutlich kleiner ist als die hinter dem React-Paket. Als die Svelte-5-kompatible Version schließlich erschienen ist, wurde bereits ein neues, vielversprechendes Feature von Sveltekit angekündigt.

Das SvelteKit-Team hatte in der Zwischenzeit Remote Functions als experimentelles Feature veröffentlicht – einen framework-nativen Ansatz für moderne, typensichere Arbeit mit Remote-Daten.

Remote Functions als Alternative

Hinweis: Dieser Abschnitt kann veraltet sein. Remote Functions sind noch ein experimentelles Feature – das SvelteKit-Team arbeitet aktiv daran, neue Features hinzuzufügen und die API weiterzuentwickeln. Die grobe Funktionsweise sollte sich allerdings nicht mehr ändern, daher sollten die genannten Konzepte weiterhin gültig sein.

Remote Functions sind SvelteKits eigene Lösung für die typsichere Kommunikation zwischen Client und Server. Sie werden immer auf dem Server ausgeführt, können aber von überall im Client aufgerufen werden.

Es gibt drei Typen:

  • query – Lesezugriff mit automatischem Caching und Deduplication
  • form – Formularübermittlung mit Validierung und progressive Enhancement
  • command – Imperative Mutationen ohne Formularbindung

Ein einfaches Query sieht so aus:

// src/lib/server/posts.remote.ts
import { query } from '$app/server';

export const getPosts = query(async () => {
  return await db.sql`SELECT title, slug FROM post`;
});
<script lang="ts">
  import { getPosts } from '$lib/server/posts.remote';

  const posts = getPosts();
</script>

<svelte:boundary>
  {#snippet pending()}
    <p>Lädt...</p>
  {/snippet}

  {#each posts as post}
    <article>{post.title}</article>
  {/each}
</svelte:boundary>

Was Remote Functions besonders interessant macht: Mehrere identische Aufrufe werden automatisch dedupliziert. getPosts() und getPosts() an unterschiedlichen Stellen im Komponentenbaum ergeben nur einen einzigen Server-Request.

Vergleich: Mutation und Invalidierung

Der vielleicht wichtigste Unterschied zu TanStack Query liegt in der Art, wie Mutationen und Cache-Invalidierung funktionieren.

TanStack Query: manuelle Invalidierung

Bei TanStack Query muss der Client nach einer Mutation explizit angeben, welche Queries neu geladen werden sollen:

<script lang="ts">
  const queryClient = useQueryClient();

  const createPost = createMutation({
    mutationFn: submitPost,
    onSuccess: () => {
      // manual: all queries using this key will be invalidated
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });
</script>

Remote Functions: Server-gesteuerte Invalidierung

Bei Remote Functions kann der Server nach einer Mutation selbst entscheiden, welche Queries neu geladen werden. Der Client muss das nicht wissen:

// src/lib/server/posts.remote.ts
import { query, form } from '$app/server';
import { redirect } from '@sveltejs/kit';
import * as v from 'valibot';

export const getPosts = query(async () => {
  return await db.sql`SELECT title, slug FROM post`;
});

const schema = v.object({
  title: v.string(),
  content: v.string(),
});

export const createPost = form(schema, async (data) => {
  await db.sql`INSERT INTO post (title, content) VALUES (${data.title}, ${data.content})`;

  // refresh only the affected query
  void getPosts().refresh();

  redirect(303, '/blog');
});

Standardmäßig invalidiert ein form-Submit alle Queries und Load-Funktionen – ähnlich wie eine klassische Seitennavigation. Wer gezielt nur bestimmte Queries neu laden möchte, nutzt .refresh().

Optimistic Updates vom Client

Für optimistische Updates – also das sofortige Anzeigen des erwarteten Ergebnisses, bevor der Server antwortet – bieten Remote Functions einen .updates()-Mechanismus:

<script lang="ts">
  import { createPost, getPosts } from '$lib/server/posts.remote';

  async function handleSubmit(data) {
    const newPost = { title: data.title, slug: slugify(data.title) };

    await createPost.submit(data).updates(
      // optimistically prepend the new post before the server responds
      getPosts().withOverride((posts) => [newPost, ...posts])
    );
  }
</script>

Der Server kann daraufhin mit requested() prüfen, welche Query-Updates der Client angefordert hat, und diese gezielt aktualisieren:

import { requested } from '$app/server';

export const createPost = form(schema, async (data) => {
  const slug = await insertPost(data);

  // check if the client requested a refresh of getPosts()
  for (const { query } of requested(getPosts)) {
    void query.refresh();
  }

  redirect(303, `/blog/${slug}`);
});

Dieses Muster ist dem von TanStack Query sehr ähnlich – nur dass die Verantwortung stärker zwischen Client (was wird optimistisch gezeigt?) und Server (was wird tatsächlich aktualisiert?) aufgeteilt ist.

Fazit

TanStack Query hat mir über Jahre gute Dienste geleistet und ist nach wie vor eine hervorragende Lösung – gerade in React-Projekten, die auf dem SPA-Modell aufsetzen, würde ich es immer wieder verwenden.

Für SvelteKit-Projekte gewinnen Remote Functions jedoch einen USP, der die Attraktivität des Frameworks nochmal stark boosten wird: Sie sind tief ins Framework integriert, benötigen keine externe Abhängigkeit, und das mentale Modell – Server entscheidet, was invalidiert wird – passt gut zu SvelteKits server-first-Ansatz.

Die Migration ist für mich ein schrittweiser Prozess. Wo früher createQuery stand, steht heute query(). Das Ergebnis ist weniger Boilerplate, Dependencies und eine klarere Trennung zwischen Client- und Server-Logik.