Prerequisites
- Basic understanding of JavaScript 📝
- TypeScript installation ⚡
- VS Code or preferred IDE 💻
- React fundamentals 🔧
- HTTP/API basics 🌐
What you'll learn
- Understand server state management fundamentals 🎯
- Apply React Query with TypeScript in real projects 🏗️
- Debug common server state issues 🐛
- Write type-safe server state code ✨
🎯 Introduction
Welcome to this exciting tutorial on React Query with TypeScript! 🎉 In this guide, we’ll explore how to manage server state like a pro using React Query’s powerful type-safe features.
You’ll discover how React Query can transform your TypeScript development experience when dealing with server data. Whether you’re building web applications 🌐, dashboards 📊, or complex data-driven interfaces 💻, understanding type-safe server state management is essential for writing robust, maintainable code.
By the end of this tutorial, you’ll feel confident using React Query with TypeScript in your own projects! Let’s dive in! 🏊♂️
📚 Understanding React Query with TypeScript
🤔 What is React Query?
React Query is like having a super-smart assistant 🤖 for your server data. Think of it as a personal butler that fetches, caches, synchronizes, and updates your server state automatically, all while keeping your TypeScript types perfectly in sync!
In TypeScript terms, React Query provides type-safe hooks for server state management 📡. This means you can:
- ✨ Get automatic type inference for your API responses
- 🚀 Enjoy IntelliSense support for all your data operations
- 🛡️ Catch server state errors at compile-time
- 🔄 Handle loading, error, and success states with type safety
💡 Why Use React Query with TypeScript?
Here’s why developers love this combination:
- Type Safety 🔒: Catch API response mismatches at compile-time
- Better IDE Support 💻: Autocomplete for API data structures
- Automatic Caching 📦: Smart data caching with type preservation
- Background Updates 🔄: Keep data fresh without losing types
- Error Handling 🛡️: Type-safe error boundaries
Real-world example: Imagine building a user dashboard 📊. With React Query and TypeScript, you can fetch user data, cache it intelligently, and know exactly what properties are available - all with compile-time safety!
🔧 Basic Syntax and Usage
📝 Installation and Setup
Let’s start by setting up React Query with TypeScript:
# 📦 Install React Query and its TypeScript types
npm install @tanstack/react-query
npm install -D @types/react
// 👋 Setting up the QueryClient
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
// 🎨 Create a query client with TypeScript configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 🕐 5 minutes
retry: 3, // 🔄 Retry failed requests 3 times
},
},
});
// 🏗️ Wrap your app with the provider
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourAppComponents />
</QueryClientProvider>
);
}
🎯 Basic Query with TypeScript
Here’s your first type-safe query:
// 🎨 Define your data types
interface User {
id: number; // 🆔 User identifier
name: string; // 👤 User's name
email: string; // 📧 Contact email
avatar?: string; // 🖼️ Optional profile picture
isActive: boolean; // ✅ Account status
}
// 🌐 API response type
interface ApiResponse<T> {
data: T;
message: string;
success: boolean;
}
// 🔧 Fetch function with proper typing
const fetchUser = async (userId: number): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user 😞');
}
const result: ApiResponse<User> = await response.json();
return result.data; // 📦 Return the typed data
};
// 🎯 Using the query hook
import { useQuery } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: number }) {
const {
data: user, // 👤 Fully typed user data!
isLoading, // ⏳ Loading state
error, // ❌ Error information
isError, // 🚨 Error flag
} = useQuery({
queryKey: ['user', userId], // 🔑 Unique identifier
queryFn: () => fetchUser(userId), // 📡 Fetch function
});
// 🎯 TypeScript knows exactly what `user` contains!
if (isLoading) return <div>Loading user data... ⏳</div>;
if (isError) return <div>Error: {error?.message} 😞</div>;
return (
<div className="user-profile">
<h2>👋 Hello, {user?.name}!</h2>
<p>📧 {user?.email}</p>
<p>Status: {user?.isActive ? '✅ Active' : '❌ Inactive'}</p>
</div>
);
}
💡 Explanation: Notice how TypeScript automatically infers the user
type from our fetch function! No manual type assertions needed.
💡 Practical Examples
🛒 Example 1: E-commerce Product Catalog
Let’s build a type-safe product catalog:
// 🛍️ Product interface
interface Product {
id: string;
name: string;
price: number;
category: 'electronics' | 'clothing' | 'books' | 'food';
rating: number;
inStock: boolean;
imageUrl?: string;
emoji: string; // 🎨 Every product needs personality!
}
// 📊 API response for product list
interface ProductListResponse {
products: Product[];
total: number;
page: number;
hasNextPage: boolean;
}
// 🔍 Search parameters type
interface ProductSearchParams {
category?: Product['category'];
minPrice?: number;
maxPrice?: number;
inStockOnly?: boolean;
page?: number;
}
// 🌐 Fetch products with filters
const fetchProducts = async (
params: ProductSearchParams = {}
): Promise<ProductListResponse> => {
const searchParams = new URLSearchParams();
// 🎯 Build query string with type safety
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined) {
searchParams.append(key, value.toString());
}
});
const response = await fetch(`/api/products?${searchParams}`);
if (!response.ok) {
throw new Error(`Failed to fetch products: ${response.status} 😞`);
}
return response.json();
};
// 🛒 Product catalog component
function ProductCatalog() {
const [filters, setFilters] = useState<ProductSearchParams>({
category: 'electronics',
inStockOnly: true,
page: 1,
});
const {
data: productData,
isLoading,
error,
isFetching, // 🔄 Background refetching
} = useQuery({
queryKey: ['products', filters], // 🔑 Refetch when filters change
queryFn: () => fetchProducts(filters),
keepPreviousData: true, // 📦 Keep old data while fetching new
});
const handleCategoryChange = (category: Product['category']) => {
setFilters(prev => ({ ...prev, category, page: 1 }));
};
if (isLoading) return <div>Loading awesome products... 🛍️</div>;
if (error) return <div>Oops! {error.message} 😞</div>;
return (
<div className="product-catalog">
<header>
<h1>🛒 Our Amazing Products</h1>
{isFetching && <span>🔄 Updating...</span>}
</header>
{/* 🎛️ Filter controls */}
<div className="filters">
<select
value={filters.category}
onChange={(e) => handleCategoryChange(e.target.value as Product['category'])}
>
<option value="electronics">📱 Electronics</option>
<option value="clothing">👕 Clothing</option>
<option value="books">📚 Books</option>
<option value="food">🍕 Food</option>
</select>
</div>
{/* 📊 Product grid */}
<div className="product-grid">
{productData?.products.map((product) => (
<div key={product.id} className="product-card">
<h3>{product.emoji} {product.name}</h3>
<p>💰 ${product.price}</p>
<p>⭐ Rating: {product.rating}/5</p>
<p>{product.inStock ? '✅ In Stock' : '❌ Out of Stock'}</p>
</div>
))}
</div>
{/* 📄 Pagination info */}
<div className="pagination-info">
<p>📊 Showing {productData?.products.length} of {productData?.total} products</p>
{productData?.hasNextPage && (
<button onClick={() => setFilters(prev => ({ ...prev, page: (prev.page || 1) + 1 }))}>
Load More 📦
</button>
)}
</div>
</div>
);
}
🎮 Example 2: Real-time Game Leaderboard
Let’s create a gaming leaderboard with live updates:
// 🏆 Player score interface
interface PlayerScore {
playerId: string;
playerName: string;
score: number;
level: number;
achievements: string[];
lastActive: string;
avatar: string; // 🎨 Player avatar emoji
}
// 🎮 Game statistics
interface GameStats {
totalPlayers: number;
activeGames: number;
topScore: number;
averageScore: number;
}
// 📊 Leaderboard response
interface LeaderboardResponse {
rankings: PlayerScore[];
stats: GameStats;
lastUpdated: string;
}
// 📡 Fetch leaderboard data
const fetchLeaderboard = async (gameId: string): Promise<LeaderboardResponse> => {
const response = await fetch(`/api/games/${gameId}/leaderboard`);
if (!response.ok) {
throw new Error(`Failed to fetch leaderboard 😞`);
}
return response.json();
};
// 🎯 Leaderboard component with real-time updates
function GameLeaderboard({ gameId }: { gameId: string }) {
const {
data: leaderboard,
isLoading,
error,
dataUpdatedAt, // 🕐 When data was last updated
} = useQuery({
queryKey: ['leaderboard', gameId],
queryFn: () => fetchLeaderboard(gameId),
refetchInterval: 30000, // 🔄 Auto-refresh every 30 seconds
refetchIntervalInBackground: true, // 🌙 Keep updating in background
});
// 🎨 Format last updated time
const formatTime = (timestamp: number): string => {
return new Date(timestamp).toLocaleTimeString();
};
if (isLoading) return <div>Loading leaderboard... 🏆</div>;
if (error) return <div>Error loading scores: {error.message} 😞</div>;
return (
<div className="game-leaderboard">
<header>
<h1>🏆 Game Leaderboard</h1>
<p>🕐 Last updated: {formatTime(dataUpdatedAt)}</p>
</header>
{/* 📊 Game statistics */}
<div className="game-stats">
<div className="stat">
<span>👥 Players</span>
<span>{leaderboard?.stats.totalPlayers}</span>
</div>
<div className="stat">
<span>🎮 Active Games</span>
<span>{leaderboard?.stats.activeGames}</span>
</div>
<div className="stat">
<span>🏆 Top Score</span>
<span>{leaderboard?.stats.topScore.toLocaleString()}</span>
</div>
</div>
{/* 🏅 Rankings */}
<div className="rankings">
{leaderboard?.rankings.map((player, index) => (
<div
key={player.playerId}
className={`player-rank ${index < 3 ? 'top-three' : ''}`}
>
<div className="rank">
{index === 0 && '🥇'}
{index === 1 && '🥈'}
{index === 2 && '🥉'}
{index > 2 && `#${index + 1}`}
</div>
<div className="player-info">
<span className="avatar">{player.avatar}</span>
<span className="name">{player.playerName}</span>
</div>
<div className="stats">
<span className="score">🎯 {player.score.toLocaleString()}</span>
<span className="level">📊 Level {player.level}</span>
<span className="achievements">🏆 {player.achievements.length}</span>
</div>
</div>
))}
</div>
</div>
);
}
🚀 Advanced Concepts
🧙♂️ Custom Hooks for Reusable Queries
Create powerful, reusable query hooks:
// 🎯 Custom hook for user data with advanced features
function useUser(userId: number, options?: {
enabled?: boolean;
refetchOnWindowFocus?: boolean;
}) {
return useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: options?.enabled ?? true,
refetchOnWindowFocus: options?.refetchOnWindowFocus ?? true,
staleTime: 5 * 60 * 1000, // 🕐 Consider fresh for 5 minutes
select: (user: User) => ({
...user,
displayName: user.name.toUpperCase(), // 🎨 Transform data
isOnline: user.isActive,
}),
});
}
// 🔄 Custom hook for infinite scrolling
function useInfiniteProducts(filters: ProductSearchParams) {
return useInfiniteQuery({
queryKey: ['products', 'infinite', filters],
queryFn: ({ pageParam = 1 }) =>
fetchProducts({ ...filters, page: pageParam }),
getNextPageParam: (lastPage) =>
lastPage.hasNextPage ? lastPage.page + 1 : undefined,
select: (data) => ({
pages: data.pages.flatMap(page => page.products),
totalProducts: data.pages[0]?.total ?? 0,
}),
});
}
🏗️ Advanced Type Patterns
Master complex TypeScript patterns with React Query:
// 🎨 Generic API response wrapper
interface ApiResult<TData, TError = string> {
data?: TData;
error?: TError;
message: string;
timestamp: string;
}
// 🚀 Generic query hook
function useApiQuery<TData, TError = string>(
endpoint: string,
options?: {
enabled?: boolean;
refetchInterval?: number;
}
) {
return useQuery<ApiResult<TData, TError>, Error>({
queryKey: [endpoint],
queryFn: async () => {
const response = await fetch(`/api${endpoint}`);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response.json();
},
enabled: options?.enabled,
refetchInterval: options?.refetchInterval,
select: (response) => response.data!, // 🎯 Extract data automatically
});
}
// 🎮 Usage with full type safety
function GameDashboard() {
// 🏆 TypeScript infers the correct types automatically!
const { data: players } = useApiQuery<PlayerScore[]>('/players/top');
const { data: stats } = useApiQuery<GameStats>('/games/stats');
// ✨ Full IntelliSense support for players and stats!
return (
<div>
<h1>🎮 Game Dashboard</h1>
<p>👥 Top Players: {players?.length}</p>
<p>📊 Active Games: {stats?.activeGames}</p>
</div>
);
}
⚠️ Common Pitfalls and Solutions
😱 Pitfall 1: Not Handling Loading States Properly
// ❌ Wrong way - ignoring loading states!
function BadUserProfile({ userId }: { userId: number }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
// 💥 Crashes if user is undefined during loading!
return <h1>Hello, {user.name}!</h1>;
}
// ✅ Correct way - handle all states!
function GoodUserProfile({ userId }: { userId: number }) {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <div>Loading profile... 👤</div>;
if (error) return <div>Error: {error.message} 😞</div>;
if (!user) return <div>User not found 🔍</div>;
return <h1>Hello, {user.name}! 👋</h1>; // ✅ Safe now!
}
🤯 Pitfall 2: Incorrect Query Key Dependencies
// ❌ Wrong - missing dependencies in query key!
function BadProductList({ category, inStock }: {
category: string;
inStock: boolean;
}) {
const { data } = useQuery({
queryKey: ['products'], // 💥 Missing category and inStock!
queryFn: () => fetchProducts({ category, inStock }),
});
// 🐛 Won't refetch when category or inStock changes!
}
// ✅ Correct - include all dependencies!
function GoodProductList({ category, inStock }: {
category: string;
inStock: boolean;
}) {
const { data } = useQuery({
queryKey: ['products', { category, inStock }], // ✅ All deps included!
queryFn: () => fetchProducts({ category, inStock }),
});
// 🎯 Refetches automatically when props change!
}
🔧 Pitfall 3: Ignoring Error Types
// ❌ Generic error handling
function WeakErrorHandling() {
const { data, error } = useQuery({
queryKey: ['data'],
queryFn: fetchSomeData,
});
if (error) {
// 😞 Generic error message - not helpful!
return <div>Something went wrong</div>;
}
}
// ✅ Specific error handling with types
interface ApiError {
code: string;
message: string;
details?: Record<string, string>;
}
function StrongErrorHandling() {
const { data, error } = useQuery<DataType, ApiError>({
queryKey: ['data'],
queryFn: async () => {
const response = await fetch('/api/data');
if (!response.ok) {
const errorData: ApiError = await response.json();
throw errorData; // 🎯 Throw typed error
}
return response.json();
},
});
if (error) {
return (
<div className="error-display">
<h3>⚠️ {error.code}</h3>
<p>{error.message}</p>
{error.details && (
<details>
<summary>🔍 Error Details</summary>
<pre>{JSON.stringify(error.details, null, 2)}</pre>
</details>
)}
</div>
);
}
}
🛠️ Best Practices
- 🎯 Use Specific Query Keys: Include all dependencies that affect the query
- 📝 Type Your Errors: Define specific error interfaces for better UX
- 🛡️ Handle All States: Always check loading, error, and empty states
- 🔄 Set Appropriate Stale Times: Balance freshness with performance
- ✨ Transform Data in Select: Keep components simple by transforming in queries
- 🎨 Create Custom Hooks: Reuse query logic across components
- 📦 Use Background Refetching: Keep data fresh without jarring UX
🧪 Hands-On Exercise
🎯 Challenge: Build a Type-Safe Comment System
Create a comment system with full TypeScript integration:
📋 Requirements:
- ✅ Fetch comments for a post with proper typing
- 🏷️ Support nested replies with recursive types
- 👤 Include user information with avatars
- 📅 Real-time updates every 30 seconds
- 🎨 Loading states and error handling
- 💬 Add optimistic mutations for new comments
🚀 Bonus Points:
- Add infinite scrolling for comments
- Implement comment voting with optimistic updates
- Create a comment search feature
- Add comment thread collapsing
💡 Solution
🔍 Click to see solution
// 🎯 Our type-safe comment system!
// 💬 Comment interface with recursive replies
interface Comment {
id: string;
content: string;
authorId: string;
authorName: string;
authorAvatar: string;
createdAt: string;
updatedAt?: string;
votes: number;
replies?: Comment[]; // 🔄 Recursive for nested comments
postId: string;
parentId?: string;
}
// 👤 User interface
interface User {
id: string;
name: string;
avatar: string;
isOnline: boolean;
}
// 📝 New comment payload
interface NewComment {
content: string;
postId: string;
parentId?: string;
}
// 📊 Comments response
interface CommentsResponse {
comments: Comment[];
totalCount: number;
hasMore: boolean;
users: Record<string, User>; // 👥 User lookup map
}
// 🌐 API functions
const fetchComments = async (postId: string, page = 1): Promise<CommentsResponse> => {
const response = await fetch(`/api/posts/${postId}/comments?page=${page}`);
if (!response.ok) throw new Error('Failed to fetch comments 😞');
return response.json();
};
const postComment = async (comment: NewComment): Promise<Comment> => {
const response = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(comment),
});
if (!response.ok) throw new Error('Failed to post comment 😞');
return response.json();
};
// 🎣 Custom hook for comments
function useComments(postId: string) {
return useQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId),
refetchInterval: 30000, // 🔄 Real-time updates
staleTime: 1000 * 60 * 2, // 🕐 2 minutes stale time
});
}
// 🎣 Custom hook for posting comments
function usePostComment() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: postComment,
onMutate: async (newComment) => {
// 🎯 Optimistic update
await queryClient.cancelQueries({ queryKey: ['comments', newComment.postId] });
const previousComments = queryClient.getQueryData(['comments', newComment.postId]);
// 🎨 Add optimistic comment
const optimisticComment: Comment = {
id: `temp-${Date.now()}`,
...newComment,
authorId: 'current-user',
authorName: 'You',
authorAvatar: '😊',
createdAt: new Date().toISOString(),
votes: 0,
};
queryClient.setQueryData(['comments', newComment.postId], (old: CommentsResponse | undefined) => {
if (!old) return old;
return {
...old,
comments: [...old.comments, optimisticComment],
totalCount: old.totalCount + 1,
};
});
return { previousComments };
},
onError: (err, newComment, context) => {
// 🔄 Rollback on error
queryClient.setQueryData(['comments', newComment.postId], context?.previousComments);
},
onSettled: (data, error, variables) => {
// 🔄 Refetch to get the real data
queryClient.invalidateQueries({ queryKey: ['comments', variables.postId] });
},
});
}
// 💬 Comment component
function CommentItem({ comment, users }: {
comment: Comment;
users: Record<string, User>;
}) {
const user = users[comment.authorId];
return (
<div className="comment">
<div className="comment-header">
<span className="avatar">{user?.avatar || '👤'}</span>
<span className="author">{comment.authorName}</span>
<span className="time">🕐 {new Date(comment.createdAt).toLocaleTimeString()}</span>
{user?.isOnline && <span className="online">🟢</span>}
</div>
<div className="comment-content">
<p>{comment.content}</p>
<div className="comment-actions">
<button>👍 {comment.votes}</button>
<button>💬 Reply</button>
</div>
</div>
{/* 🔄 Recursive replies */}
{comment.replies && comment.replies.length > 0 && (
<div className="replies">
{comment.replies.map((reply) => (
<CommentItem key={reply.id} comment={reply} users={users} />
))}
</div>
)}
</div>
);
}
// 🏗️ Main comments section
function CommentsSection({ postId }: { postId: string }) {
const [newComment, setNewComment] = useState('');
const {
data: commentsData,
isLoading,
error,
dataUpdatedAt
} = useComments(postId);
const postCommentMutation = usePostComment();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!newComment.trim()) return;
postCommentMutation.mutate({
content: newComment,
postId,
});
setNewComment(''); // 🎯 Clear form
};
if (isLoading) return <div>Loading comments... 💬</div>;
if (error) return <div>Error loading comments: {error.message} 😞</div>;
return (
<div className="comments-section">
<header>
<h3>💬 Comments ({commentsData?.totalCount || 0})</h3>
<small>🕐 Updated: {new Date(dataUpdatedAt).toLocaleTimeString()}</small>
</header>
{/* 📝 New comment form */}
<form onSubmit={handleSubmit} className="comment-form">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Share your thoughts... 💭"
disabled={postCommentMutation.isPending}
/>
<button
type="submit"
disabled={!newComment.trim() || postCommentMutation.isPending}
>
{postCommentMutation.isPending ? 'Posting... ⏳' : 'Post Comment 📝'}
</button>
</form>
{/* 💬 Comments list */}
<div className="comments-list">
{commentsData?.comments.map((comment) => (
<CommentItem
key={comment.id}
comment={comment}
users={commentsData.users}
/>
))}
</div>
{commentsData?.hasMore && (
<button className="load-more">
Load More Comments 📦
</button>
)}
</div>
);
}
🎓 Key Takeaways
You’ve learned so much! Here’s what you can now do:
- ✅ Set up React Query with TypeScript with full type safety 💪
- ✅ Create type-safe queries that catch errors at compile-time 🛡️
- ✅ Handle loading and error states professionally 🎯
- ✅ Build reusable custom hooks for clean code organization 🐛
- ✅ Implement optimistic updates for great UX 🚀
- ✅ Use advanced patterns like infinite queries and real-time updates ✨
Remember: React Query + TypeScript = Server state management made easy! It’s here to help you build robust, type-safe applications. 🤝
🤝 Next Steps
Congratulations! 🎉 You’ve mastered React Query with TypeScript!
Here’s what to do next:
- 💻 Practice with the comment system exercise above
- 🏗️ Build a project with real API integration
- 📚 Explore React Query DevTools for debugging
- 🌟 Learn about React Query mutations and form handling
- 🚀 Move on to our next tutorial: Redux Toolkit with TypeScript
Remember: Every React Query expert was once a beginner. Keep experimenting with server state, keep learning, and most importantly, have fun building amazing user experiences! 🚀
Happy coding! 🎉🚀✨