Skip to content

Avoiding Common Mistakes with TanStack Query Part 2

Published:

It has been a while since Part 1. Since then, I have started using TanStack Router and it is again gold from the TanStack team as usual. This post is a bit of a mix — some mistakes I keep seeing, some patterns I started loving, and how Query and Router work together.

Same disclaimer as before: Ship first. These are patterns that make your life easier, but if your code works and your users are happy, you are doing fine.

4. Query key management is a mess

This is probably the most common thing I see. Query keys scattered across the codebase, sometimes as strings, sometimes as arrays, and nobody remembers what the convention was supposed to be.

// Somewhere in a component
useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

// Somewhere else, slightly different
useQuery({ queryKey: ['todo-list'], queryFn: fetchTodos })

// And then the invalidation
queryClient.invalidateQueries({ queryKey: ['todos'] }) // does this cover ['todo-list']? no.

This is how you end up with stale data and spend an hour debugging why your list is not updating after a mutation.

Solution: Centralize your keys

There are two approaches I like.

Approach 1: Query key factory

You can roll your own or use something like @lukemorales/query-key-factory. The idea is simple — define all your keys in one place with a consistent structure.

export const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}

Now when you invalidate todoKeys.lists(), it invalidates all list queries regardless of filters. The hierarchy does the work for you. No more guessing if your invalidation actually hit the right query.

// Fetching
useQuery({
  queryKey: todoKeys.list('completed'),
  queryFn: () => fetchTodos('completed'),
})

// Invalidation — hits all list queries
queryClient.invalidateQueries({ queryKey: todoKeys.lists() })

Approach 2: Generate from your API spec

If you have a Swagger/OpenAPI spec, tools like Hey API can generate your query keys and hooks automatically. This is especially nice because your keys always match your backend and you never have to think about them.

// hey-api generates something like this
const query = useQuery({
  ...getPetByIdOptions({
    path: { petId: 1 },
  }),
})

The query key, the types, the fetch function — all generated from your spec. One less thing to maintain. I have started using this on a project with a large API and it is a huge time saver.

5. Loaders are your friend

I have been using TanStack Router lately and one of the best things about it is route loaders. A loader runs before the component mounts, which means the data can be ready before the user even sees the page.

The pattern I see a lot (and used to do myself) is fetching everything inside the component:

const UserRoute = createFileRoute('/users/$userId')({
  component: UserComponent,
})

function UserComponent() {
  const { userId } = useParams({ from: '/users/$userId' })

  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => apiService.getUser(userId),
  })

  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => apiService.getUserPosts(userId),
  })

  // loading spinner time...
}

This works, but the component mounts first, then the fetches start, then you wait. The user sees a spinner for no good reason.

Use the loader with ensureQueryData

const UserRoute = createFileRoute('/users/$userId')({
  loader: async ({ params, context }) => {
    const { queryClient } = context

    await Promise.all([
      queryClient.ensureQueryData({
        queryKey: ['user', params.userId],
        queryFn: () => apiService.getUser(params.userId),
      }),
      queryClient.ensureQueryData({
        queryKey: ['posts', params.userId],
        queryFn: () => apiService.getUserPosts(params.userId),
      }),
    ])
  },
  component: UserComponent,
})

function UserComponent() {
  const { userId } = useParams({ from: '/users/$userId' })

  // These return instantly — data is already in cache
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => apiService.getUser(userId),
  })

  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => apiService.getUserPosts(userId),
  })

  // No loading state needed here
  return <div>{user.name}</div>
}

ensureQueryData fetches if the data is not cached, returns the cached data if it is. The component renders with data already there. No content shift, no spinner.

And when the user navigates away and comes back, ensureQueryData returns the cached data instantly (assuming staleTime has not expired). Clean and fast.

This is not specific to TanStack Router by the way. React Router has loaders too. The concept is the same — load data before the component renders.

6. Access parent loader data in child routes

This one is really useful for layout-level data. Say you have a user profile page with nested tabs — overview, posts, settings. The user data is the same for all tabs, so you load it once in the parent route.

// routes/users/$userId.tsx — parent route
const UserRoute = createFileRoute('/users/$userId')({
  loader: async ({ params }) => {
    const user = await apiService.getUser(params.userId)
    return { user }
  },
  component: UserLayout,
})

function UserLayout() {
  const { user } = Route.useLoaderData()

  return (
    <div>
      <h1>{user.name}</h1>
      <nav>
        <Link to="/users/$userId" params={{ userId: user.id }}>Overview</Link>
        <Link to="/users/$userId/posts" params={{ userId: user.id }}>Posts</Link>
      </nav>
      <Outlet />
    </div>
  )
}

// routes/users/$userId/posts.tsx — child route
const UserPostsRoute = createFileRoute('/users/$userId/posts')({
  loader: async ({ params }) => {
    const posts = await apiService.getUserPosts(params.userId)
    return { posts }
  },
  component: UserPosts,
})

function UserPosts() {
  // Access THIS route's loader data
  const { posts } = Route.useLoaderData()

  // Access PARENT route's loader data
  const { user } = useLoaderData({ from: '/users/$userId' })

  return (
    <div>
      <h2>{user.name}'s Posts</h2>
      {posts.map(post => <div key={post.id}>{post.title}</div>)}
    </div>
  )
}

The from parameter tells the hook which route’s data you want. This is typesafe — TypeScript will warn you if the route does not match or the data shape is wrong.

This is really handy for things like breadcrumbs, shared filters, or any data that a group of routes needs. You load it once in the parent, consume it everywhere below. No prop drilling, no context providers, no extra fetches.

7. Polling with data-aware intervals

Sometimes you need to poll an API until something changes. A classic example: you submit a job, the API returns a job ID, and you need to keep checking until the status changes from “in_progress” to “success” or “fail”.

The naive approach is a fixed interval:

const { data } = useQuery({
  queryKey: ['job', jobId],
  queryFn: () => apiService.getJob(jobId),
  refetchInterval: 2000,
})

This polls every 2 seconds forever. Even after the job is done. Not great.

Use a function for refetchInterval

refetchInterval can be a function that receives the query and returns the interval (or false to stop).

const { data } = useQuery({
  queryKey: ['job', jobId],
  queryFn: () => apiService.getJob(jobId),
  refetchInterval: (query) => {
    const status = query.state.data?.status
    if (status === 'success' || status === 'fail') {
      return false // stop polling
    }
    return 2000 // keep going
  },
})

That is it. The polling stops itself when the job is done. No useEffect, no local state, no manual cleanup.

You can also do more creative things with it. Want to back off and poll slower after a while?

refetchInterval: (query) => {
  const status = query.state.data?.status
  if (status === 'success' || status === 'fail') {
    return false
  }
  const failureCount = query.state.data?.failureCount ?? 0
  return failureCount > 3 ? 10000 : 2000 // slow down after 3 failures
},

The query object gives you access to state.data, state.error, and other internals. Plenty of information to make polling decisions.

Conclusion

The TL;DR for this one:

  1. Centralize your query keys. Use a factory or generate from your API spec. Your future self will thank you.
  2. Load data before your component mounts. Route loaders with ensureQueryData prevent spinners and content shifts.
  3. Share loader data across nested routes. useLoaderData with from is your friend for layout data.
  4. Let polling stop itself. Pass a function to refetchInterval instead of managing it manually.

If you have any questions or comments, feel free to reach out to me!