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.

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.
useQuery accepts a single configuration object with two required members: queryKey and queryFn.
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.
useQuery returns many status properties. The four most important ones for beginners:
queryFn.queryFn throws an error.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.
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:
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()
},
})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:
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.
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.
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:
manual: useState + useEffect + fetch + setData + setLoading + setError
useQuery: useQuery( queryKey, queryFn ) → data + isLoading + isError + isFetchingThe 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.
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.queryKey and read from the context.throw an error in queryFn so the error status and retry work.useState or useEffect needed for data fetching.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.