Zum Inhalt springen
tecminds

useQuery vs useEffect: Ein Next.js-Dashboard von Fetch-Hooks zu TanStack Query refaktorieren

Ein Next.js-Dashboard auf useState + useEffect + fetch wurde zum Geflecht aus Races, blinkenden Loadern und veralteten Listen. Die Migration zu TanStack Query liess 100 Zeilen State-Plumbing auf ein useQuery zusammenschrumpfen — und behob die Bugs, die wir ständig flickten.

TTobias LüscherCo‑Founder · TecMinds2026-06-15 · 10 Min Lesezeit

useQuery vs useEffect: Ein Next.js-Dashboard von Fetch-Hooks zu TanStack Query refaktorieren

Die General-Attendance-Seite war das schlimmste Screen im Produkt. Lädt man sie zweimal neu, blinkt der Loader manchmal kurz durch leere Zeilen, bevor die Daten ankommen. Reicht man eine Entschuldigung für einen Nutzer ein, bleibt die Zählung im Header falsch, bis man auf eine andere Ansicht klickt und wieder zurück. Öffnet man die Seite in einem alten Tab, zeigt die Liste stillschweigend den gestrigen Bestand. Für jeden dieser Punkte hatten wir ein Jira-Ticket. Jeder sah wie ein eigenständiges Problem aus. Keiner war es.

Das ist die Aufzeichnung der useQuery vs useEffect-Migration, die das ganze Paket beerdigt hat — drei useEffect-Blöcke gelöscht, rund 100 Zeilen useState-Plumbing auf vier useQuery-Hooks geschrumpft und eine Mutation neu auf queryClient.setQueryData verdrahtet, damit die Zahlen nach dem Submit ohne Netzwerk-Roundtrip stimmen. Das Spannende ist nicht der Diff — sondern die Bug-Kategorie, die verschwindet, sobald ein Request-Cache ausserhalb des React-State lebt.

Das Refactoring landete in der ZSO-Management-Codebasis — einer Plattform für die Operationen der Schweizer Zivilschutzorganisationen, die wir betreuen und in der das Anwesenheits-Dashboard an jedem Übungstag von Zugführern angefasst wird. Dasselbe Anti-Pattern findet sich in praktisch jedem Next.js-Dashboard, das auf useState + useEffect + fetch aufgebaut ist — also in den meisten.

Das useEffect-Muster, das gut aussieht, bis es nicht mehr funktioniert

Der ursprüngliche Code war Lehrbuch. Eine Page-Komponente hält den aktuellen Nutzer, die Liste der Anwesenheitslisten, den Tagesbestand und das User-Array — jedes in seinem eigenen useState, jedes gespeist von einem eigenen useEffect, der einen fetch abfeuert, das JSON parst und das Ergebnis zurückschreibt. Boilerplate, aber lesbar:

const [currentUser, setCurrentUser] = useState<UserSession | null>(null)
const [attendanceLists, setAttendanceLists] = useState<AttendanceList[]>([])
const [attendanceData, setAttendanceData] = useState({
  attendances: [],
  totalPresent: 0,
  missingUsers: [] as MissingUser[],
  totalExpected: 0,
})
const [users, setUsers] = useState<UserSelectionItem[]>([])
const [loading, setLoading] = useState(true)

useEffect(() => {
  async function loadUser() {
    try {
      const user = await getCurrentUser()
      setCurrentUser(user)
    } catch (error) {
      console.error("Failed to load current user:", error)
    }
  }
  loadUser()
}, [])

useEffect(() => {
  if (!currentUser) return
  const fetchAttendanceLists = async () => {
    try {
      setLoading(true)
      const response = await fetch(`/api/attendance-lists?organizationId=${Number(currentUser.primaryOrganizationId)}`)
      // ...dreissig Zeilen Response-Handling, Default-State und Error-Logging
    } finally {
      setLoading(false)
    }
  }
  fetchAttendanceLists()
}, [currentUser])

Das React-Team hat in den neuen Docs eine ganze Seite namens You Might Not Need an Effect, und das allererste Anti-Pattern, das sie nennen, ist genau das obige: "Fetching data" mit useEffect. Die Gründe klingen abstrakt, bis man sie ausgeliefert hat.

Die erste Race-Condition ist die offensichtliche. Zwei Effekte hängen von currentUser ab, beide feuern beim Eintreffen, beide rufen setLoading(true), beide setzen es im jeweiligen finally zurück auf false. Wer zuletzt auflöst, gewinnt — ausser wenn nicht, weil der Nutzer zwischen den beiden await-Punkten "Datum ändern" klickt und ein dritter Effekt zur Party kommt. Wir haben das zweimal mit useRef-Flags geflickt und dann aufgegeben.

Die subtilere Race ist der "Entschuldigung einreichen, Header bleibt falsch"-Bug. Die Mutation POST /api/general-attendance/excuse liefert den aktualisierten Anwesenheits-Payload im Response-Body zurück. Der alte Handler schrieb diesen Body direkt mit setAttendanceData(...) in den lokalen State. So weit, so gut. Aber es gibt auch den Datums-getriebenen Effekt, der auf selectedDate hört und die Anwesenheitsdaten selbständig neu lädt. Wenn der Nutzer nach dem Submit, aber bevor die Response eintraf, das Datum änderte, überschrieb der alte Handler die frischen Daten mit der inzwischen veralteten Response. Jeder Fix, den wir versuchten, fügte ein weiteres Flag oder einen weiteren Guard hinzu.

Der dritte ist "gestriger Bestand." Die Seite hat kein Konzept eines Request-Caches, also wird beim Öffnen jedes Mal von Null geladen, ohne geteilten State zwischen Tabs und ohne Invalidierungs-Kontrakt zwischen Mutationen und Queries. Wenn das Backend langsam antwortete und der Nutzer wegnavigierte, malte das nächste Render aus lokalem State, der seit dem letzten Besuch unberührt geblieben war. Der Datensatz ist klein genug, dass es wochenlang niemandem auffiel.

Keiner dieser Punkte ist ein React-Bug. Es sind Symptome davon, dass man lokalen Komponenten-State benutzt, um einen Request-Cache zu modellieren. Wenn man es einmal ausspricht, ist der Fix offensichtlich: Modelliere den Request-Cache als Request-Cache.

Was TanStack Query tatsächlich anders macht

TanStack Query — früher React Query — ist im Kern ein einzelner globaler Key-Value-Store laufender und aufgelöster Requests plus eine kleine reaktive Schicht, mit der Komponenten Einträge per Key abonnieren. Jeder useQuery-Aufruf ist ein Abonnement, kein Fetch. Wenn zwei Komponenten denselben Key abfragen, feuert genau ein Request und beide erhalten das Ergebnis. Wenn eine Mutation weiss, dass sie die Daten unter einem Key geändert hat, kann sie diesen Key invalidieren und alle Abonnenten laden bei nächster Gelegenheit nach. Wenn eine Mutation die neue Form der Daten unter einem Key kennt, kann sie sie direkt per setQueryData schreiben und den Roundtrip ganz überspringen.

Die Migration ersetzt drei sequenzielle Effekte durch drei unabhängige Queries. Hier die neue Form derselben Seite:

const { data: currentUser = null } = useQuery<UserSession | null>({
  queryKey: ['current-user'],
  queryFn: () => getCurrentUser(),
  staleTime: Infinity,
})

const orgId = currentUser ? Number(currentUser.primaryOrganizationId) : null

const { data: attendanceLists = [], isLoading: listsLoading, refetch: refetchLists } = useQuery({
  queryKey: ['attendance-lists', orgId],
  queryFn: async () => {
    const response = await fetch(`/api/attendance-lists?organizationId=${orgId}`)
    if (!response.ok) return []
    return response.json()
  },
  enabled: !!orgId,
})

const { data: attendanceData = defaultAttendance, isLoading: attendanceLoading } = useQuery({
  queryKey: ['general-attendance', formattedDate, selectedListId],
  queryFn: () => fetch(attendanceUrl).then(r => r.json()),
  enabled: !!orgId,
})

const loading = listsLoading || attendanceLoading

Drei Dinge leisten in diesem Snippet echte Arbeit, und keines davon ist sichtbar, wenn man es als syntaktische Verkürzung des alten Musters liest.

Erstens: queryKey ist der Kontrakt. ['general-attendance', formattedDate, selectedListId] sagt: Die Identität dieser Query hängt von diesen beiden Inputs ab — ändert sich einer, geht der Cache-Lookup daneben und ein neuer Fetch läuft, automatisch, ohne Effekt. Die "Datum geändert, neu laden"-Verdrahtung löscht sich selbst.

Zweitens: enabled: !!orgId ist die Abhängigkeits-Deklaration, die der alte Code als if (!currentUser) return in einem Effekt ausdrückte. Dieselbe Idee, aber sie steht direkt neben der Query-Definition und nicht in einem imperativen Block fünf Zeilen weiter unten. Die Query ist ein Wert, kein Seiteneffekt, und der Wert deklariert seine eigenen Voraussetzungen.

Drittens: staleTime: Infinity auf dem aktuellen Nutzer ist die Art billiger Optimierung, die man im alten Muster ohne eigenen Cache nicht ausdrücken kann. Die User-Session ändert sich höchstens einmal pro Page-Lifetime, der vernünftige Default ist also "lade einmal, halte ewig, lade nicht im Hintergrund nach." Jede Komponente, die den aktuellen Nutzer will, ruft denselben Hook mit demselben Key auf und bekommt den gecachten Wert sofort. Das ist dieselbe Intuition, die im zentralen Boundary-Guard unseres Posts zu abgeschnittenem LLM-JSON steckt: Wenn etwas überall wahr ist, modelliere es einmal an der untersten Schicht und vergiss es danach.

Die Mutations-Hälfte: setQueryData schlägt setState

Die Hälfte des Refactorings, die es nicht in die Schlagzeile schafft, ist die Mutations-Verdrahtung — und genau dort ist der "Header bleibt nach Submit veraltet"-Bug tatsächlich gestorben.

Der alte Excuse-Submit-Handler rief fetch('/api/general-attendance/excuse', {...}) auf, parste die Response und kippte den Payload in lokales setAttendanceData(...). Das funktionierte, solange die Seite die einzige Kopie der Daten hielt. Sobald die Daten im Query-Cache leben, erreicht "lokalen State aktualisieren" den Cache nicht mehr, und das nächste Render — das den Cache abonniert, nicht den lokalen State — malt aus dem, was der Cache noch glaubt.

Der neue Handler schreibt die Response in den Cache unter demselben Key, den die Query abonniert:

queryClient.setQueryData(
  ['general-attendance', formattedDate, selectedListId],
  {
    attendances: responseData.attendances || [],
    totalPresent: responseData.totalPresent || 0,
    missingUsers: responseData.missingUsers || [],
    totalExpected: responseData.totalExpected || 0,
  },
)

queryClient.invalidateQueries({ queryKey: ['/api/lessons'] })

setQueryData ist ein optimistischer Schreibvorgang: Der Cache springt sofort um, jeder Abonnent rendert mit dem neuen Payload neu, kein Netzwerk-Roundtrip. Der Header zeigt die richtige Zahl, bevor der Nutzer blinzelt. Das nachgeschobene invalidateQueries kümmert sich um Seiteneffekte auf verwandte Daten — eine eingereichte Entschuldigung verändert anderswo in der App auch Lektions-Ansichten, also invalidieren wir diesen Key, der Lektions-Screen weiss, dass er beim nächsten Mount neu laden soll, und wir müssen einem "Lektions-Ansicht zeigt falsche Absenz-Zahl"-Bug nie wieder hinterherjagen.

Das ist der Kontrakt, den das alte Muster schlicht nicht ausdrücken konnte: "Ich habe gerade Daten X geändert; hier der neue Wert von X; markiere ausserdem Y als veraltet." Diese beiden Operationen als separate Primitive zu modellieren ist das, was den Rest der App selbstheilend macht. Jedes andere Dashboard im Produkt, das Anwesenheitsdaten abonniert, bekommt das Update gratis, sobald der Nutzer absendet — ohne dass irgendeines dieser Screens überhaupt weiss, dass das Excuse-Formular existiert. Dieselbe logische Trennung steckt in der Per-Endpoint-Disziplin unseres FastAPI-Rate-Limit-Header-Posts: Kontrakte, die dort leben, wo die Daten leben, sind billiger zu warten als Kontrakte, die sich an jeder Call-Site neu implementieren.

Drei Regeln, die du vor dem nächsten useEffect mitnehmen solltest

Falls du genau eine Sache aus der Migration mitnimmst: Benutze useEffect für Effekte, nicht für Data Fetching. Netzwerk-Requests haben einen Lebenszyklus, den das Effect-Modell nicht abdeckt — Caching, Deduplikation, Invalidierung, optimistische Writes, Stale-while-Revalidate. Wer das mit useState plus useEffect plus einem Fetch-Wrapper ausdrücken will, landet bei den vier Bugs, die wir ständig geflickt haben: Races zwischen parallelen Effekten, veraltete Daten nach einer Mutation, verpasste Invalidierungen bei verwandten Queries und ein Loader, der durch den Zustandsübergang blinkt, den der Nutzer gerade erwischt.

Drei Regeln tragen den Rest.

Lass den queryKey die Dependency-Liste sein. Jedes Mal, wenn du useEffect(() => { fetch(/api/x?date=${date}) }, [date]) schreibst, baust du von Hand das nach, was queryKey: ['x', date] für dich erledigen würde — und du wirst irgendwann eine Abhängigkeit vergessen. Die ganze Lint-Kategorie react-hooks/exhaustive-deps existiert, weil Dependency-Tracking genau das Problem ist, das Menschen falsch machen. Den Cache-Key das Refetching steuern zu lassen, ist dasselbe Prinzip wie das Typsystem die Serialisierung steuern zu lassen: lehn dich dort ans Framework an, wo es stark ist.

Paare jede Mutation mit setQueryData oder invalidateQueries. Enthält die Mutation-Response den neuen Zustand einer Query, schreib ihn per setQueryData ein, damit Abonnenten ihn gratis bekommen. Invalidiert die Mutation nur die alten Daten, ohne dir die neue Form zu liefern, ruf invalidateQueries auf, damit das nächste Render einen Refetch auslöst. Der Mutation-Handler, der keines von beiden tut, ist der, der den Stale-Header-Bug ausliefert. Der Handler, der beides tut — Cache schreiben, Abhängige invalidieren — ist der, der jeden anderen Screen in der App selbst aktualisieren lässt.

Zieh enabled in die Query, nicht in die umliegende Kontrollfluss-Logik. Das alte if (!currentUser) return in einem Effekt ist die imperative Version von enabled: !!currentUser an der Query. Die deklarative Version komponiert: Du kannst abhängige Queries verketten (enabled: !!orgId auf der nächsten Ebene), ohne Effekte zu verschachteln oder eine State Machine von Hand zu bauen. Abhängige Queries sind im imperativen Muster eine ganze Bug-Kategorie und im deklarativen eine Konfigurationszeile.

Das General-Attendance-Refactoring war das erste Dashboard in der Codebasis, das migriert wurde. Das Muster verallgemeinert sich auf jeden Screen, der mehr als eine Sache lädt — besonders die, in denen ein Fetch von einem anderen abhängt oder eine Mutation State in drei verschiedenen Komponenten aktualisieren muss. Jeder davon ist ein Kandidat für dieselbe Aufräumaktion — und die meisten haben ein offenes Jira-Ticket gegen ein Symptom, das mit dem Refactoring verschwinden wird.

Wenn du auf ein Next.js-Dashboard starrst, dessen Data-Fetching-Code zu einem Stapel useEffects gewachsen ist, und du vor der Migration ein zweites Augenpaar willst, buch einen kostenlosen AI Potenzial-Check — oder lies, wie wir über die Skalierung von AI-Agenten vom Pilot in die Produktion denken, für den breiteren Architekturkontext.

NÄCHSTER SCHRITTHat dich das interessiert?