How I Learned to Stop Worrying and Love the Tanstack Query - Part 1

A detailed introduction to next-level data fetching with Tanstack Query

JavaScriptProgrammingWeb DevelopmentTypeScriptQueryTanstack

What is the fuss about?

Let's be honest here – if you've been writing React apps for more than five minutes, you've probably found yourself in this delightful situation what I call the "I'll just quickly fetch some data" starter pack:

// 1. A state for the data itself.
const [todos, setTodos] = useState([]);

// 2. A state to track the initial loading.
const [loading, setLoading] = useState(false);

// 3. A state to hold any potential errors.
const [error, setError] = useState(null);

// 4. And maybe even ANOTHER state for subsequent fetches... it gets messy.
const [refetching, setRefetching] = useState(false);

// 5. The classic data-fetching effect hook.
useEffect(() => {
  // Set loading to true before we start the fetch.
  setLoading(true);

  fetch('/api/todos')
    .then(res => res.json())
    .then(data => {
      // On success, we update our data and stop loading.
      setTodos(data);
      setLoading(false);
    })
    .catch(err => {
      // On failure, we store the error and stop loading.
      setError(err);
      setLoading(false);
    });
  // The empty dependency array means this runs only once on mount.
}, []);

// 6. Now we need a separate function for refetching, often repeating logic.
const refetchTodos = () => {
  setRefetching(true);
  // ... and we'd have to copy-paste the same fetch logic again 🤦‍♂️
};

While there is nothing inherently anything wrong about this approach, I have to concur that we can do better, way better!

This is where Tanstack Query comes to rescue.

What Exactly Is TanStack Query?

Think of TanStack Query as your personal data-fetching butler. It's sophisticated, it handles all the messy details. It's a querying and state management library that takes all the painful parts of data fetching and wraps them up in a neat little package with a bow on top.

In the most basic terms, TanStack Query provides hooks that come with:

  • Reactive state management
  • Built-in loading states
  • Error handling that doesn't make you cry
  • Automatic refetching capabilities
  • Caching that's so smart, it might be plotting world domination

Under the hood, it uses a query client that keeps track of all your queries and automatically caches them, making subsequent requests faster than your morning coffee kicks in.

TLDR; it's a querying and state management library that turns this:

// The old way: Pain and suffering
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);

useEffect(() => {
  // 15 lines of fetch logic...
}, [dependency1, dependency2, dependency3]);

Into this:


// The TanStack Query way: Pure bliss
const { 
  data,       // Our data will be here
  isLoading,  // A boolean for the loading state
  error       // An object for the error state
} = useQuery({
  // A unique key to identify and cache this query.
  queryKey: ['todos'],
  // The function that will fetch the data. It must return a promise.
  queryFn: fetchTodos
});

It's like magic, but the kind that actually works in production.

⚠️

Please be mindful of the word I used: "cached" state management because henceforth we will be managing not the state but the "cached" version of it!

Let's Begin

In this blog, we will be using React and build a simple todo app. If you want to use Nextjs, you can just adjust the setup as stated below, otherwise feel free to skip. For further reading regarding Nextjs and SSR please refer to: Tanstack query docs

Getting Started: The Setup Ritual

First things first, let's get this library into your project:

npm install @tanstack/react-query

Now, before we can start querying, we need to wrap our app in a query client provider. This is like introducing TanStack Query to your app and saying, "These two need to be friends."

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

// 1. Create a new instance of the QueryClient.
//    It's crucial to create this OUTSIDE the component to prevent it
//    from being recreated on every re-render.
const queryClient = new QueryClient();

function App() {
  return (
    // 2. Wrap your application with the QueryClientProvider.
    //    Pass the client instance you created to the `client` prop.
    <QueryClientProvider client={queryClient}>
      {/* 3. Now, any component inside this provider can use TanStack Query hooks. */}
      <YourAppComponents />
    </QueryClientProvider>
  );
}

Pro tip: Make sure that queryClient lives outside of your React component. We don't want it getting recreated every time React decides to re-render.

The useQuery Hook: Your New Best Friend

The heart and soul of TanStack Query is the useQuery hook. This little beauty is going to replace all those messy combinations of useState, useEffect, and tears.

Here's how it works:

import { useQuery } from '@tanstack/react-query';

// This is our actual data fetching function.
// It's just a standard async function that returns a promise.
async function fetchTodos() {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos');
  if (!response.ok) {
    throw new Error('Network response was not ok');
  }
  return response.json();
}

function TodoList() {
  // 1. Call the useQuery hook with a unique key and your fetch function.
  const { data, isLoading, error } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  // 2. TanStack Query provides a simple boolean for the initial loading state.
  if (isLoading) {
    return <div>Loading your life away...</div>;
  }

  // 3. It also provides a dedicated state for handling errors.
  if (error) {
    return <div>Something went wrong: {error.message}</div>;
  }

  // 4. If not loading and no error, `data` is available and typed!
  //    We use optional chaining `data?.map` as a good practice.
  return (
    <div>
      {data?.map(todo => (
        <div key={todo.id}>{todo.title}</div>
      ))}
    </div>
  );
}

The Anatomy of useQuery

Let's break this down:

Query Key: The Unique Identifier

The queryKey is like a name tag for your query. It needs to be unique because that's how TanStack Query knows which query is which. Think of it as the difference between calling your friend "John" and calling your other friend "John from accounting"—specificity matters. If you shout "John" in a room full of Johns, people get confused. This is exactly why we need queryKey, it lets the Tanstack know which data to cache. Do not worry, we will talk about caching, stale and so on in the future.

Query Function: Where the Magic Happens

The queryFn is where you put your actual API call. This is your fetch, axios, or whatever HTTP client you're using to grab your data.

The Beauty of Built-in State Management

Remember the days when you had to manually track loading states, error states, and data states? Those days are over. TanStack Query gives you all of this out of the box:

const { 
  data,       // Your actual data, once successfully fetched.
  
  isPending,  // The new, recommended state. True if the query has no data and is fetching for the first time.
  isLoading,  // An alias for `isPending`. Kept for backward compatibility. Use this for a full-page skeleton loader.

  isFetching, // True anytime the query function is running, including background refetches. 
              // Perfect for a subtle loading spinner that doesn't block the UI.
  
  isError,    // A boolean flag, true if the query encountered an error.
  error,      // The actual error object itself, so you can display a message.

  refetch,    // A function you can call to manually trigger a refetch of the data.
} = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
});

Want to add a loading spinner? Use isPending. Want to handle errors gracefully? Use isError and error. Want to refetch data when the user clicks a button? Just call refetch(). It's that simple.

Parameters and Dynamic Queries

Real-world applications rarely deal with static data. You'll often need to pass parameters to your queries. Here's how you handle that:

async function fetchComments(postId) {
  const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${postId}/comments`);
  return response.json();
}

function PostComments({ postId }) {
  const { data: comments, isLoading } = useQuery({
    // 1. **CRITICAL**: The query key is now an array that includes the dynamic `postId`.
    //    This tells TanStack Query that `['comments', 1]` is a different query
    //    from `['comments', 2]`, and it will cache them separately.
    queryKey: ['comments', postId],

    // 2. The query function is now an anonymous function so we can pass the `postId`.
    queryFn: () => fetchComments(postId),
  });

  // ... rest of your component
}

Critical point: Always include your parameters in the query key! If you don't, TanStack Query won't be able to differentiate between different parameter combinations, and you'll end up with cached data when you shouldn't.

Conditional Querying: The "enabled" Option

Sometimes you don't want a query to run automatically. Maybe you want to wait for user input, or maybe you need data from another query first. This is where the enabled option comes in handy:

function UserProfile({ userId }) {
  // 1. We have a local state to control when the query should be active.
  const [shouldFetchPosts, setShouldFetchPosts] = useState(false);

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

    // 2. The magic `enabled` option. The query will NOT run as long as this is false.
    //    It will automatically trigger once it becomes true.
    enabled: shouldFetchPosts,
  });

  return (
    <div>
      {/* 3. A user action, like clicking a button, can change the state and enable the query. */}
      <button onClick={()=> setShouldFetchPosts(true)}>
        Load Posts
      </button>

      {/* 4. Render the posts once they are fetched. */}
      {posts && <PostList posts={posts} />}
    </div>
  );
}

Modularity!

Organizing Your Queries: The Query Options Pattern

For the past couple of years, I have worked on some big apps, like millions of citizens with hundreds of different fetch requests, mutations and so on. As your app grows, you'll want to keep your query options organized. It will keep you sane, trust me:

import { queryOptions } from '@tanstack/react-query';

// This is our standard fetch function.
async function fetchTodos() {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos');
  return response.json();
}

// 1. We create a factory function that returns a pre-configured query object.
//    The `queryOptions` helper provides type-safety and autocompletion.
export function createTodoQueryOptions() {
  return queryOptions({
    // 2. Define the query key here. It's now co-located with its fetching logic.
    queryKey: ['todos'],
    // 3. Define the query function.
    queryFn: fetchTodos,
    // 4. You can add any other shared options here, like `staleTime` or `gcTime`.
    //    staleTime: 5 * 60 * 1000, // e.g., data is fresh for 5 minutes
  });
}

Now, using this in a component becomes incredibly clean and reusable.

import { createTodoQueryOptions } from './queries/todoQueries';

function TodoList() {
  // 1. Simply call our factory function to get the query options.
  const todoQueryOptions = createTodoQueryOptions();

  // 2. Pass the entire options object directly to `useQuery`.
  //    This is clean, reusable, and ensures consistency across your app.
  const { data, isLoading } = useQuery(todoQueryOptions);

  // ... rest of your component
}

This pattern makes your queries reusable across your entire app and keeps things organized.

TypeScript Integration: Because Types Are Your Friends

If you're using TypeScript (and you should be, you beautiful, organized developer), you'll want to add types to your query functions: You can use Zod here, but I will talk about it in the future.

// 1. Define the shape of a single 'Todo' item.
interface Todo {
  userId: number;
  id: number;
  title: string;
  completed: boolean;
}

// 2. Type the return value of your fetch function as a Promise that resolves to an array of Todos.
async function fetchTodos(): Promise<Todo[]> {
  const response = await fetch('https://jsonplaceholder.typicode.com/todos');
  return response.json();
}

function MyComponent() {
  const { data, isLoading } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  });

  // 3. Magic! Because `fetchTodos` returns `Promise<Todo[]>`, TypeScript now knows
  //    that `data` is of type `Todo[] | undefined`. You get full autocompletion!
  data?.forEach(todo => {
    console.log(todo.title); // Autocomplete for .title, .id, etc.
  });
}

Now your data will be properly typed, and TypeScript will catch any property access errors before they reach production.

useSuspenseQuery: For the Suspense Lovers

If you're using React Suspense (and you should consider it), TanStack Query has you covered with useSuspenseQuery:

import { Suspense } from 'react';
import { useSuspenseQuery } from '@tanstack/react-query';

function TodoList() {
  // 1. Use `useSuspenseQuery` instead of `useQuery`.
  const { data } = useSuspenseQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos
  });

  // 2. **Key difference**: `data` is GUARANTEED to be defined here.
  //    The hook doesn't return `isLoading` or `error` because Suspense handles it.
  //    If the query is loading, the component suspends, and the fallback is shown.
  //    If it errors, the nearest <ErrorBoundary> catches it.
  return (
    <div>
      {data.map(todo => (
        <div key={todo.id}>{todo.title}</div>
      ))}
    </div>
  );
}

// Wrap your component in a Suspense boundary
function App() {
  return (
    // 3. The `fallback` prop defines what to render while `TodoList` (or any child) is suspended.
    <Suspense fallback={<div>Loading with Suspense...</div>}>
      <TodoList />
    </Suspense>
  );
}

The key difference is that useSuspenseQuery guarantees that data will never be undefined, making your code cleaner and more predictable.

Multiple Queries: Because Sometimes You Need More

Sometimes you need to run multiple queries in the same component. TanStack Query has a couple of approaches for this:

Independent Queries with useQueries

When you have multiple queries that don't depend on each other:

function Dashboard() {
  // 1. `useQueries` takes an object with a `queries` property, which is an array of query options.
  const results = useQueries({
    queries: [
      { queryKey: ['todos'], queryFn: fetchTodos },
      { queryKey: ['users'], queryFn: fetchUsers },
      { queryKey: ['posts'], queryFn: fetchPosts }
    ]
  });

  // 2. It returns an array of query results, in the same order as the options you provided.
  const [todosQuery, usersQuery, postsQuery] = results;

  // 3. Now you can check the status of each query individually and render its data.
  return (
    <div>
      {todosQuery.isSuccess && <TodoList todos={todosQuery.data} />}
      {usersQuery.isSuccess && <UserList users={usersQuery.data} />}
      {postsQuery.isSuccess && <PostList posts={postsQuery.data} />}
    </div>
  );
}

Dependent Queries with Conditional Fetching

When one query depends on another:

function UserPosts({ userId }) {
  // 1. First, we fetch the user's data.
  const { data: user } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetchUser(userId)
  });

  // 2. Then, we fetch the user's posts.
  const { data: posts } = useQuery({
    queryKey: ['posts', userId],
    queryFn: () => fetchUserPosts(userId),
    // 3. This query is only enabled if the `user` object from the first query exists.
    //    `!!user` converts the `user` object (or undefined) to a boolean.
    //    The query will wait until the first one succeeds.
    enabled: !!user
  });

  return (
    <div>
      {user && <UserProfile user={user} />}
      {posts && <PostList posts={posts} />}
    </div>
  );
}

The Caching Magic: Why Your App Feels Lightning Fast

Here's where TanStack Query really shines. Remember how I mentioned it caches your queries? This means:

  1. No duplicate requests: If two components need the same data, only one request is made
  2. Instant subsequent loads: Data is served from cache while being refreshed in the background
  3. Smart invalidation: You can selectively invalidate cached data when needed

The caching happens automatically based on your query keys. It's like having a really smart assistant who remembers everything and knows exactly when to refresh information.

Common Patterns and Best Practices

1. Always Include Dynamic Values in Query Keys

// Good ✅: The query key uniquely identifies this specific data request.
// TanStack Query knows that ['posts', 5, 'published'] is different from ['posts', 5, 'draft'].
const { data } = useQuery({
  queryKey: ['posts', userId, status],
  queryFn: () => fetchPosts(userId, status)
});

// Bad ❌: This will cause problems!
// If `userId` or `status` changes, TanStack Query will think it's the same query
// because the key is static. It will likely return stale, incorrect cached data.
const { data } = useQuery({
  queryKey: ['posts'],
  queryFn: () => fetchPosts(userId, status)
});

2. Keep Query Functions Pure

// Good ✅: This function only fetches and returns data. It's predictable and testable.
const fetchTodos = async () => {
  const response = await fetch('/api/todos');
  return response.json();
};

// Questionable ❓: This function has a "side effect."
const fetchTodos = async () => {
  const response = await fetch('/api/todos');
  const data = await response.json();
  
  // This is a side effect. It modifies state outside of its own scope.
  // This makes the function less predictable and harder to reuse.
  // Use the `onSuccess` callback in `useQuery` for this instead.
  setGlobalState(data); 
  
  return data;
};

3. Use Suspense Boundaries Wisely

function App() {
  return (
    // 1. This <Suspense> boundary will catch any suspending components inside it.
    // 2. It will show a single <PageLoader /> if the entire <Dashboard /> suspends,
    //    or if any component inside the dashboard suspends.
    <Suspense fallback={<PageLoader />}>
      <Dashboard />
    </Suspense>
  );
}

Conclusion

TanStack Query isn't just a library—it's a paradigm shift. It takes the complexity of data fetching and state management and makes it feel almost... easy? Before I started using it, I was always thinking about managing the state but after using it, it has completely changed the way I perceive data. I am contantly making decisions to how to manage the "cached" state.

For the next part of this blog, I will deep-dive into Tanstack Query and explore some more advanced topics like State and Fresh data, Mutations, Infinite Queries, Pagination, Placeholder Data so on and on!

Stay tuned!