In my journey building complex web applications, from robust WordPress plugins like the OpenWA WhatsApp Gateway to full-stack Laravel ERP systems, one constant challenge has been managing asynchronous data. Traditional approaches often lead to a tangled mess of loading states, error handling, and refetching logic spread throughout components. This is where tools like React Query (now TanStack Query) become indispensable, especially when combined with the power of TypeScript for type safety.
Today, I want to share my practical experience on how to use React Query with TypeScript examples, providing you with actionable insights to streamline your data fetching, caching, and state management in your React applications. We'll move beyond the theoretical to discuss real-world patterns I've implemented in projects like my Frontend File Explorer plugin and various client portals.
Why React Query and TypeScript are a Game-Changer
Before diving into code, let's talk about why this combination is so powerful. I've worked on projects where data fetching logic was scattered, leading to bugs, inconsistent UI, and a debugging nightmare. React Query steps in to centralize and optimize this. It provides hooks for fetching, caching, synchronizing, and updating server state in your React apps, abstracting away a lot of the boilerplate.
The Benefits I've Experienced with React Query:
- Automatic Caching: No more manual cache management. React Query intelligently caches fetched data, drastically improving perceived performance, especially for frequently accessed resources. In my OpenWA WhatsApp Gateway plugin's admin panel, for example, fetching message logs or customer data is significantly faster after the initial load.
- Simplified State Management: It separates server state from client state, making your global state much cleaner. I used to rely heavily on Redux or Context API for server state, but React Query handles it all with specific `useQuery` and `useMutation` hooks.
- Optimistic Updates: This is huge for user experience. When a user performs an action (like deleting a file in my Frontend File Explorer), you can immediately update the UI as if the change was successful, then revert if the server request fails. This makes applications feel incredibly responsive.
- Background Refetching: Data can become stale. React Query automatically refetches data in the background when components mount, window re-focuses, or when a specific interval passes, ensuring your UI is always up-to-date without blocking the user.
- Error Handling & Retries: Robust error handling and configurable retries are built-in, reducing the amount of custom logic you need to write.
Why TypeScript Adds Essential Value:
Adding TypeScript to React Query is like having a guardian angel for your data. In projects like the School ERP (Laravel) where I manage student data, fees, and attendance, the data structures can be complex. TypeScript:
- Ensures Type Safety: You define the shape of your data (e.g., a `Student` object, a `FeeTransaction` array). This prevents runtime errors that often occur when data from the API doesn't match what your component expects.
- Enhances Developer Experience: IDEs provide excellent autocompletion and static analysis. When I'm consuming an API endpoint for, say, a list of repair jobs in my Point of Sale application, TypeScript immediately tells me if I'm trying to access a property that doesn't exist on the `job` object.
- Improves Code Readability and Maintainability: Explicit types make your code easier to understand for anyone (including your future self) who works on the project. This is crucial for long-term projects and team environments.
Setting Up Your Project for React Query with TypeScript
Let's get started. Assuming you have a React project set up, the first step is to install the necessary packages:
# Using npm
npm install @tanstack/react-query
# Or using yarn
yarn add @tanstack/react-query
Next, you need to wrap your application or the relevant part of it with the `QueryClientProvider` and create an instance of `QueryClient`. This client manages all the caching and state for React Query.
// src/App.tsx or src/index.tsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')!).render(
{/* Optional: React Query Devtools for debugging */}
,
);
The `ReactQueryDevtools` are incredibly useful for visualizing your cache, queries, and mutations. I always include them during development; they've saved me countless hours debugging data issues in both my WordPress plugin admin interfaces and standalone React apps.
Basic Data Fetching: `useQuery` with TypeScript Examples
The `useQuery` hook is the cornerstone of React Query for fetching data. When you're trying to figure out how to use React Query with TypeScript examples for fetching, this is where you start. Let's define some types for a hypothetical `Post` data structure, similar to what I might deal with when fetching data for a custom post type in a WordPress plugin, or product details in a POS system.
Defining Your Types
// src/types.ts
export interface Post {
id: number;
title: string;
body: string;
userId: number;
}
export interface User {
id: number;
name: string;
email: string;
}
Fetching Data with `useQuery`
Now, let's fetch a list of posts and a single user using these types:
// src/components/PostsList.tsx
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { Post, User } from '../types';
// A simple async function to fetch posts
const fetchPosts = async (): Promise => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
// A simple async function to fetch a single user
const fetchUser = async (userId: number): Promise => {
const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
const PostsList: React.FC = () => {
// Fetching all posts
const { data: posts, isLoading: postsLoading, error: postsError } = useQuery({
queryKey: ['posts'],
queryFn: fetchPosts
});
// Fetching a single user (e.g., the first post's user, if posts exist)
const userIdToFetch = posts?.[0]?.userId;
const { data: user, isLoading: userLoading, error: userError } = useQuery({
queryKey: ['user', userIdToFetch],
queryFn: () => fetchUser(userIdToFetch!),
enabled: !!userIdToFetch // Only run this query if userIdToFetch is available
});
if (postsLoading || userLoading) return Loading posts and user...;
if (postsError) return Error fetching posts: {(postsError as Error).message};
if (userError) return Error fetching user: {(userError as Error).message};
return (
All Posts
{user && (
User fetched: {user.name} ({user.email})
)}
{posts?.map((post) => (
-
{post.title} by User ID: {post.userId}
{post.body}
))}
);
};
export default PostsList;

Notice how `useQuery` automatically infers the return type from `queryFn` if you provide a `Promise
Handling Data Mutations: `useMutation` with TypeScript Examples
While `useQuery` is for fetching, `useMutation` is for sending data to the server (creating, updating, deleting). This is where things like optimistic updates and invalidating cached queries come into play. In my School ERP, when a new student is enrolled, or in my POS system when an item is added to an order, `useMutation` is the hook I reach for.
Defining Mutation Types
// src/types.ts (continued)
export interface CreatePostPayload {
title: string;
body: string;
userId: number;
}
// The response might be the created post with an ID
export interface CreatedPostResponse extends Post {}
Creating a Post with `useMutation` and Optimistic Updates
This example demonstrates creating a new post, with an optimistic update. The UI updates immediately, and if the API call fails, the UI reverts. This pattern is invaluable for a smooth user experience, for instance, when deleting a file in my Frontend File Explorer or marking an order complete in OpenWA.
// src/components/CreatePostForm.tsx
import React, { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { CreatePostPayload, CreatedPostResponse, Post } from '../types';
const createPost = async (newPost: CreatePostPayload): Promise => {
const response = await fetch('https://jsonplaceholder.typicode.com/posts', {
method: 'POST',
body: JSON.stringify(newPost),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
});
if (!response.ok) {
throw new Error('Failed to create post');
}
return response.json();
};
const CreatePostForm: React.FC = () => {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [userId, setUserId] = useState(1);
const createPostMutation = useMutation({
mutationFn: createPost,
onMutate: async (newPost) => {
// Cancel any outgoing refetches (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['posts'] });
// Snapshot the previous value
const previousPosts = queryClient.getQueryData(['posts']);
// Optimistically update to the new value
queryClient.setQueryData(['posts'], (old) => [
...(old || []),
{ id: Date.now(), ...newPost } // Assign a temporary ID
]);
return { previousPosts };
},
onError: (err, newPost, context) => {
// If the mutation fails, use the context for a rollback
queryClient.setQueryData(['posts'], context?.previousPosts);
alert(`Error creating post: ${err.message}`);
},
onSettled: () => {
// Always refetch after error or success: ensures client side is in sync with server
queryClient.invalidateQueries({ queryKey: ['posts'] });
},
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
createPostMutation.mutate({ title, body, userId });
setTitle('');
setBody('');
};
return (
);
};
export default CreatePostForm;




