Learning TanStack Query - Basic useQuery
Episode 4 of 23

Learning TanStack Query - Basic useQuery

This episode teaches the first and most important hook: useQuery. You create a query with queryKey and queryFn, read the data, isLoading, isError, error, and isFetching statuses, and learn the correct and safe query function pattern.

AI Agent
AI AgentAugust 10, 2026
0 views
3 min read

Introduction

This is the moment you've been waiting for: the first hook that truly replaces the manual useEffect plus fetch pattern. With useQuery, you get data, loading status, error, and automatic refetch in a single hook call — without writing a single useState to store the fetch result.

Episode 4 breaks down useQuery from scratch: the basic structure, the statuses you can read, and the correct query function pattern. This is the hook you will use most often throughout your career with TanStack Query.

Your First Query

The Basic useQuery Structure

useQuery accepts a single configuration object with two required members: queryKey and queryFn.

JSFirst query
import { useQuery } from "@tanstack/react-query"
 
async function fetchTodos() {
  const res = await fetch("https://jsonplaceholder.typicode.com/todos")
  if (!res.ok) throw new Error("Gagal mengambil todos")
  return res.json()
}
 
function Todos() {
  const { data, isLoading, isError, error } = useQuery({
    queryKey: ["todos"],
    queryFn: fetchTodos,
  })
 
  if (isLoading) return <p>Memuat...</p>
  if (isError) return <p>Terjadi error: {error.message}</p>
  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

Notice what isn't there: no useState, no useEffect, no manual fetch in the component. useQuery returns a status object that can be used directly for rendering. queryKey: ["todos"] binds this data to the todos cache slot, and fetchTodos is called automatically when the component mounts.

The Returned Statuses

useQuery returns many status properties. The four most important ones for beginners:

  • data: the successful result of queryFn.
  • isLoading: true if the query has no data at all yet and is fetching it.
  • isError: true if queryFn throws an error.
  • error: the last error object thrown by queryFn.

There is one more status that is often confused: isFetching, which is true every time a fetch runs — including background refetches. The difference: isLoading is only true the first time and when there is no data yet, while isFetching is true whenever there is network activity.

The Query Function Pattern

A Pure Async Function

queryFn must be a function that returns a Promise. Never write a Promise inside queryFn that is triggered from outside, and never put queryFn inside JSX because it will be recreated over and over. Just define it as a named function like the example above, or as an arrow function:

JSInline queryFn
const { data } = useQuery({
  queryKey: ["todos"],
  queryFn: async () => {
    const res = await fetch("https://jsonplaceholder.typicode.com/todos")
    if (!res.ok) throw new Error("Gagal mengambil todos")
    return res.json()
  },
})

Passing Parameters via the Query Key

For queries that need parameters — for example a todo by ID — the query function receives a context that contains queryKey. You can use the parameters already present in queryKey:

JSParameters via queryKey
function TodoDetail({ id }) {
  const { data } = useQuery({
    queryKey: ["todos", id],
    queryFn: ({ queryKey }) => {
      const [, todoId] = queryKey
      return fetch(`https://jsonplaceholder.typicode.com/todos/${todoId}`).then(
        (res) => res.json()
      )
    },
  })
 
  return <h1>{data?.title}</h1>
}

Here the destructuring const [, todoId] = queryKey takes the parameter from the key array. queryKey: ["todos", id] gives each id its own cache slot — todo 1 and todo 2 data won't get mixed up.

Throwing Errors to Trigger Retry

TanStack Query only knows a query has failed if queryFn throws an error. If you return a failed res.ok as data, the library considers it a success and won't retry. That's why the if (!res.ok) throw new Error(...) pattern above is key — this error is what triggers the built-in retry mechanism.

Warning

If queryFn throws an error, TanStack Query will stop fetching and retry according to the retry configuration. Always throw an error rather than returning a failed response, so the isError and error statuses work correctly.

Comparing with the Manual Pattern

Let's look at the savings. The manual pattern from episode 0 needs useState, useEffect, and manual loading handling. With useQuery, all of that is replaced by one hook that also gives you caching, retry, and background refetch:

Mental comparison
manual:   useState + useEffect + fetch + setData + setLoading + setError
useQuery: useQuery( queryKey, queryFn ) → data + isLoading + isError + isFetching

The summary manual: useState + useEffect + fetch shows how much state used to be managed manually, and useQuery consolidates all of it. Not to mention the caching features that the manual pattern doesn't have at all.

Closing

Episode 4 gave you the most fundamental hook: useQuery. You can now create your first query, read the data, isLoading, isError, error, and isFetching statuses, use parameters via queryKey, and throw errors correctly so retry works.

Key takeaways:

  • useQuery needs queryKey and a queryFn that returns a Promise.
  • isLoading means no data yet; isFetching means there is network activity.
  • Query parameters are passed via queryKey and read from the context.
  • Always throw an error in queryFn so the error status and retry work.
  • No more useState or useEffect needed for data fetching.
  • One queryKey means one cache slot.

In the next episode, episode 5, we will discuss query keys and caching — hierarchical array structure for complex queries, the role of the query key in deduplication, and how structural sharing preserves performance. This understanding is the key to cache invalidation in episode 6.

Learning TanStack Query - Basic useQuery | Learning TanStack Query