Skip to content
tecminds

useQuery vs useEffect: Refactoring a Next.js Dashboard from Fetch Hooks to TanStack Query

A Next.js dashboard built on useState + useEffect + fetch turned into a tangle of races, dead loaders, and stale lists. Migrating to TanStack Query collapsed 100 lines of state plumbing into one useQuery — and fixed the bugs we kept patching.

TTobias LüscherCo‑Founder · TecMinds2026-06-15 · 10 min read

useQuery vs useEffect: Refactoring a Next.js Dashboard from Fetch Hooks to TanStack Query

The general-attendance page was the worst-behaved screen in the product. Refresh it twice and the loader sometimes flashed empty rows before the data came in. Submit an excuse for a user and the count in the header stayed wrong until you clicked away and back. Open it on a stale tab and the list silently showed yesterday's roster. Each of those was a bug we had a Jira ticket for. Each of them looked like a separate problem. None of them were.

This is the writeup of the useQuery vs useEffect migration that retired the lot of them — three useEffect blocks deleted, roughly 100 lines of useState plumbing collapsed into four useQuery hooks, and one mutation rewired to queryClient.setQueryData so the post-submit numbers update without a network roundtrip. The interesting part isn't the diff — it's the category of bug that disappears the moment a request-cache lives outside React state.

The refactor landed in the ZSO-management codebase — a Swiss civil-protection (Zivilschutzorganisation) operations platform we maintain, where the attendance dashboard is hit by platoon leaders every drill day. Same anti-pattern shows up in roughly every Next.js dashboard built on useState + useEffect + fetch — which is to say, most of them.

The useEffect Pattern That Looks Fine Until It Doesn't

The original code was textbook. A page-level component holds the current user, the list of attendance lists, the per-day attendance payload, and the users array — each in its own useState, each fed by its own useEffect that fires a fetch, parses the JSON, and writes the result back. Boilerplate, but legible:

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)}`)
      // ...thirty lines of response handling, default-state setting, and error logging
    } finally {
      setLoading(false)
    }
  }
  fetchAttendanceLists()
}, [currentUser])

The React team has a whole page in the new docs called You Might Not Need an Effect, and the very first anti-pattern they call out is the one above: "Fetching data" with useEffect. The reasons sound abstract until you've shipped them.

The first race is the obvious one. Two effects depend on currentUser, both fire when it lands, both call setLoading(true), both reset it to false in their own finally. Whichever resolves last wins — except when they don't, because the user clicks "change date" between the two await points and a third effect joins the party. We patched this twice with useRef flags and gave up.

The subtler race is the "submit an excuse and the header stays wrong" bug. The mutation POST /api/general-attendance/excuse returns the updated attendance payload in the response body. The old handler wrote that body straight into local state with setAttendanceData(...). So far so good. But there's also the date-driven effect that listens to selectedDate and re-fetches the attendance data on its own. If the user changed the date after submitting but before the response arrived, the old handler overwrote the fresh data with the now-stale response. Every fix we tried added another flag or another guard.

The third one is "yesterday's roster." The page has no concept of a request cache, so opening it always re-fetches from scratch, with no shared state between tabs and no invalidation contract between mutations and queries. When the backend's response was slow and the user navigated away, the next render painted from local state that hadn't been touched since the last visit. The dataset is small enough that nobody noticed for weeks.

None of these are React bugs. They're symptoms of using local component state to model a request cache. Once you say it out loud, the fix is obvious: model the request cache as a request cache.

What TanStack Query Actually Does Differently

TanStack Query — formerly React Query — is, at heart, a single global key-value store of in-flight and resolved requests, plus a small reactive layer that lets components subscribe to entries by key. Every useQuery call is a subscription, not a fetch. If two components ask for the same key, exactly one request fires and both get the result. If a mutation knows it just changed the data under a key, it can invalidate that key and every subscriber refetches at the next opportunity. If a mutation knows the new shape of the data under a key, it can write it directly via setQueryData and skip the round trip entirely.

The migration replaces three sequenced effects with three independent queries. Here's the new shape of the same page:

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

Three things are doing real work in that snippet, and none of them are visible if you read it as a syntactic shortening of the old pattern.

First, queryKey is the contract. ['general-attendance', formattedDate, selectedListId] says: this query's identity depends on those two inputs, so if either changes, the cache lookup misses and a new fetch runs — automatically, without an effect. The "date changed, refetch" wiring deletes itself.

Second, enabled: !!orgId is the dependency declaration the old code expressed as if (!currentUser) return inside an effect. It's the same idea, but it lives next to the query definition, not inside an imperative block five lines down. The query is a value, not a side effect, and the value advertises its own preconditions.

Third, staleTime: Infinity on the current user is the kind of cheap optimisation you cannot express in the old pattern without writing a custom cache. The user session changes at most once per page lifetime, so a sane default is "fetch it once, hold it forever, do not background-refetch." Every component that wants the current user calls the same hook with the same key and gets the cached value instantly. This is the same instinct that drives the centralised boundary guard in our LLM truncated JSON post: when something is true everywhere, model it once at the lowest layer, then forget about it.

The Mutation Half: setQueryData Beats setState

The half of the refactor that doesn't make it into the headline number is the mutation rewiring, and it is where the post-submit-stale-header bug actually died.

The old excuse-submit handler called fetch('/api/general-attendance/excuse', {...}), parsed the response, and dropped the payload into local setAttendanceData(...). That worked while the page held the only copy of the data. Once the data lives in the query cache, "update local state" doesn't reach the cache, and the next render — which subscribes to the cache, not to local state — paints from whatever the cache still believes.

The new handler writes the response into the cache under the same key the query subscribes to:

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 is an optimistic write: the cache flips immediately, every subscriber re-renders with the new payload, no network round trip. The header count is correct before the user blinks. The follow-up invalidateQueries is the part that handles side effects on related data — submitting an excuse changes lesson views elsewhere in the app, so we invalidate that key, the lessons screen knows to refetch the next time it mounts, and we never have to chase down a "lesson view shows the wrong absence count" bug separately.

This is the contract the old pattern simply could not express: "I just changed data X; here's the new value of X; also mark Y as stale." Modelling those two operations as separate primitives is what makes the rest of the app self-healing. Every other dashboard in the product that subscribes to attendance data gets the update for free the moment the user submits, without any of those screens knowing that the excuse form exists. The same logical separation drives the per-endpoint discipline in our FastAPI rate limit headers post: contracts that live where the data lives are cheaper to maintain than contracts that re-implement themselves at every call site.

Three Rules to Steal Before Your Next useEffect

If you take one thing from the migration: use useEffect for effects, not for data fetching. Network requests have a lifecycle that the effect model doesn't capture — caching, deduplication, invalidation, optimistic writes, stale-while-revalidate. Trying to express any of those with useState plus useEffect plus a fetch wrapper leads to the four bugs we kept patching: races between concurrent effects, stale data after a mutation, missed invalidations on related queries, and a loader that flashes through whichever state transition the user happens to catch.

Three rules carry the rest of the weight.

Let the queryKey be the dependency list. Every time you write useEffect(() => { fetch(/api/x?date=${date}) }, [date]), you're hand-rolling what queryKey: ['x', date] would do for you — and you'll forget a dependency eventually. The whole react-hooks/exhaustive-deps lint category exists because dependency tracking is exactly the problem humans get wrong. Letting the cache key drive refetch is the same principle as letting the type system drive serialisation: lean on the framework where it's strong.

Pair every mutation with either setQueryData or invalidateQueries. If the mutation response contains the new state of a query, write it in with setQueryData so subscribers get it for free. If the mutation only invalidates the old data without giving you the new shape, call invalidateQueries so the next render triggers a refetch. The mutation handler that does neither is the one that ships the stale-header bug. The handler that does both — write the cache, invalidate the dependents — is the handler that makes every other screen in the app self-update.

Pull enabled into the query, not into the surrounding control flow. The old if (!currentUser) return inside an effect is the imperative version of enabled: !!currentUser on a query. The declarative version composes: you can chain dependent queries (enabled: !!orgId on the next layer down) without nesting effects or building a state machine by hand. Dependent queries are an entire category of bug in the imperative pattern and a one-line config in the declarative one.

The general-attendance refactor was the first dashboard in the codebase to migrate. The pattern generalises to every screen that fetches more than one thing, especially the ones where one fetch depends on another, or where a mutation has to update state visible in three different components. Every one of those is a candidate for the same cleanup — and most of them have a Jira ticket open against a symptom that will disappear with the refactor.

If you're staring at a Next.js dashboard whose data-fetching code has accreted into a stack of useEffects and you want a second pair of eyes on the migration before it lands, book a free AI Potenzial-Check — or read how we think about scaling AI agents from pilot to production for the broader architectural context.

NEXT STEPWas this useful?