I used to manage async state with useState and useEffect. Loading states, error states, refetch logic, caching—all hand-rolled. My components were 200 lines of state management and 50 lines of actual UI.
Then I discovered React Query (now TanStack Query). Suddenly, all that boilerplate disappeared. But the real magic happened when I learned to use it with TypeScript properly.
TypeScript + React Query isn't just about catching errors. It's about making impossible states impossible, getting perfect autocomplete, and never wondering "what does this data look like again?"
Let me show you how to use them together the right way.
Setup: Getting the Types Right
First, install React Query v5 (the latest):
npm install @tanstack/react-query
Basic setup:
// App.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Pattern 1: Basic Queries with Type Safety
The Old Way (Manual Async State)
function UserProfile({ userId }: { userId: string }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setIsLoading(true);
setError(null);
fetchUser(userId)
.then(data => {
setUser(data);
setIsLoading(false);
})
.catch(err => {
setError(err);
setIsLoading(false);
});
}, [userId]);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return <div>{user.name}</div>;
}
Problems:
- Boilerplate everywhere
- No caching
- No refetch on window focus
- No automatic retries
- Manual loading/error state coordination
The React Query Way
import { useQuery } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
function UserProfile({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
// TypeScript KNOWS data is User here (not User | undefined)
return <div>{data.name}</div>;
}
What React Query gives you:
- Automatic caching (same userId = cached result)
- Background refetch on window focus
- Automatic retries on failure
- Stale-while-revalidate pattern
- Perfect TypeScript inference
Pattern 2: Type-Safe Query Keys
Query keys are critical. They determine caching and invalidation. Make them type-safe.
The Problem: String Keys
// ❌ Easy to make typos, no autocomplete
useQuery({ queryKey: ['usr', userId], queryFn: fetchUser });
useQuery({ queryKey: ['user', usrId], queryFn: fetchUser }); // Typo!
The Solution: Query Key Factory
// queryKeys.ts
export const queryKeys = {
users: {
all: ['users'] as const,
lists: () => [...queryKeys.users.all, 'list'] as const,
list: (filters: string) => [...queryKeys.users.lists(), filters] as const,
details: () => [...queryKeys.users.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.users.details(), id] as const,
},
posts: {
all: ['posts'] as const,
lists: () => [...queryKeys.posts.all, 'list'] as const,
list: (filters: string) => [...queryKeys.posts.lists(), filters] as const,
details: () => [...queryKeys.posts.all, 'detail'] as const,
detail: (id: string) => [...queryKeys.posts.details(), id] as const,
},
} as const;
// Usage - fully typed, autocomplete everywhere!
const { data } = useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
});
// Invalidate all user queries
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
// Invalidate specific user
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(userId) });
Benefits:
- Single source of truth
- Autocomplete for query keys
- No typos possible
- Easy refactoring
- Clear cache hierarchy
Pattern 3: Generic Query Hooks
Create reusable, type-safe query hooks.
// hooks/useUser.ts
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
interface User {
id: string;
name: string;
email: string;
}
async function fetchUser(userId: string): Promise<User> {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
return response.json();
}
export function useUser(
userId: string,
options?: Omit<UseQueryOptions<User, Error>, 'queryKey' | 'queryFn'>
) {
return useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
...options,
});
}
// Usage
function UserProfile({ userId }: { userId: string }) {
const { data: user, isLoading, error } = useUser(userId);
// All the benefits of useQuery, wrapped in a clean API
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user.name}</div>;
}
// With custom options
function UserProfileWithRefetch({ userId }: { userId: string }) {
const { data: user } = useUser(userId, {
staleTime: 1000 * 60 * 10, // 10 minutes
refetchOnWindowFocus: false,
});
return <div>{user?.name}</div>;
}
Pattern 4: Mutations with Perfect Types
Mutations are for CREATE, UPDATE, DELETE operations.
Basic Mutation
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface CreateUserData {
name: string;
email: string;
}
interface User {
id: string;
name: string;
email: string;
}
async function createUser(data: CreateUserData): Promise<User> {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to create user');
return response.json();
}
function CreateUserForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createUser,
onSuccess: (newUser) => {
// TypeScript knows newUser is User
console.log('Created:', newUser.name);
// Invalidate and refetch
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
onError: (error) => {
// TypeScript knows error is Error
console.error('Failed:', error.message);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" required />
<input name="email" type="email" required />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Creating...' : 'Create User'}
</button>
{mutation.isError && <div>Error: {mutation.error.message}</div>}
{mutation.isSuccess && <div>User created successfully!</div>}
</form>
);
}
Generic Mutation Hook
interface UpdateUserData {
name?: string;
email?: string;
}
async function updateUser(
userId: string,
data: UpdateUserData
): Promise<User> {
const response = await fetch(`/api/users/${userId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) throw new Error('Failed to update user');
return response.json();
}
function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ userId, data }: { userId: string; data: UpdateUserData }) =>
updateUser(userId, data),
onSuccess: (updatedUser, variables) => {
// Invalidate specific user query
queryClient.invalidateQueries({
queryKey: queryKeys.users.detail(variables.userId)
});
// Invalidate user lists
queryClient.invalidateQueries({
queryKey: queryKeys.users.lists()
});
},
});
}
// Usage
function EditUserForm({ user }: { user: User }) {
const updateUser = useUpdateUser();
const handleSubmit = (data: UpdateUserData) => {
updateUser.mutate({ userId: user.id, data });
};
return (
<form onSubmit={(e) => {
e.preventDefault();
handleSubmit({ name: 'New Name' });
}}>
{/* Form fields */}
</form>
);
}
Pattern 5: Optimistic Updates
Update UI immediately, then sync with server.
The Full Pattern
interface Todo {
id: string;
text: string;
completed: boolean;
}
function useToggleTodo() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (todoId: string) => {
const response = await fetch(`/api/todos/${todoId}/toggle`, {
method: 'POST',
});
if (!response.ok) throw new Error('Failed to toggle todo');
return response.json();
},
// Optimistic update
onMutate: async (todoId) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: queryKeys.todos.lists() });
// Snapshot previous value
const previousTodos = queryClient.getQueryData<Todo[]>(
queryKeys.todos.lists()
);
// Optimistically update
queryClient.setQueryData<Todo[]>(
queryKeys.todos.lists(),
(old) => old?.map((todo) =>
todo.id === todoId
? { ...todo, completed: !todo.completed }
: todo
)
);
// Return context with snapshot
return { previousTodos };
},
// If mutation fails, rollback
onError: (err, todoId, context) => {
queryClient.setQueryData(
queryKeys.todos.lists(),
context?.previousTodos
);
},
// Always refetch after error or success
onSettled: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.todos.lists() });
},
});
}
// Usage
function TodoItem({ todo }: { todo: Todo }) {
const toggleTodo = useToggleTodo();
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo.mutate(todo.id)}
/>
<span>{todo.text}</span>
</div>
);
}
What happens:
- User clicks checkbox → UI updates immediately
- Request sent to server in background
- If server confirms → keep the optimistic update
- If server fails → rollback to previous state
- Either way → refetch to ensure consistency
Pattern 6: Infinite Queries
For pagination and infinite scroll.
interface PostsResponse {
posts: Post[];
nextCursor: string | null;
}
interface Post {
id: string;
title: string;
content: string;
}
async function fetchPosts(cursor: string | null = null): Promise<PostsResponse> {
const url = cursor
? `/api/posts?cursor=${cursor}`
: '/api/posts';
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch posts');
return response.json();
}
function useInfinitePosts() {
return useInfiniteQuery({
queryKey: queryKeys.posts.lists(),
queryFn: ({ pageParam }) => fetchPosts(pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage) => lastPage.nextCursor,
select: (data) => ({
pages: data.pages,
posts: data.pages.flatMap((page) => page.posts),
}),
});
}
// Usage
function PostsList() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfinitePosts();
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
{hasNextPage && (
<button
onClick={() => fetchNextPage()}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load More'}
</button>
)}
</div>
);
}
Infinite Scroll with Intersection Observer
function PostsListWithInfiniteScroll() {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useInfinitePosts();
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
const target = observerTarget.current;
if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1 }
);
observer.observe(target);
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
return (
<div>
{data?.posts.map((post) => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
<div ref={observerTarget} />
{isFetchingNextPage && <div>Loading more...</div>}
</div>
);
}
Pattern 7: Dependent Queries
Query B depends on data from Query A.
function UserPosts({ userId }: { userId: string }) {
// First query: get user
const { data: user, isLoading: isLoadingUser } = useUser(userId);
// Second query: get user's posts (disabled until we have user)
const { data: posts, isLoading: isLoadingPosts } = useQuery({
queryKey: queryKeys.posts.list(userId),
queryFn: () => fetchUserPosts(userId),
enabled: !!user, // Only run when user exists
});
if (isLoadingUser) return <div>Loading user...</div>;
if (!user) return <div>User not found</div>;
if (isLoadingPosts) return <div>Loading posts...</div>;
return (
<div>
<h1>{user.name}'s Posts</h1>
{posts?.map((post) => (
<article key={post.id}>{post.title}</article>
))}
</div>
);
}
Pattern 8: Parallel Queries
Fetch multiple queries simultaneously.
function Dashboard({ userId }: { userId: string }) {
const userQuery = useUser(userId);
const postsQuery = useQuery({
queryKey: queryKeys.posts.list(userId),
queryFn: () => fetchUserPosts(userId),
});
const statsQuery = useQuery({
queryKey: ['stats', userId],
queryFn: () => fetchUserStats(userId),
});
// All queries run in parallel
const isLoading = userQuery.isLoading || postsQuery.isLoading || statsQuery.isLoading;
if (isLoading) return <div>Loading dashboard...</div>;
return (
<div>
<UserInfo user={userQuery.data!} />
<UserPosts posts={postsQuery.data!} />
<UserStats stats={statsQuery.data!} />
</div>
);
}
With useQueries for Dynamic Lists
function MultiUserDashboard({ userIds }: { userIds: string[] }) {
const userQueries = useQueries({
queries: userIds.map((id) => ({
queryKey: queryKeys.users.detail(id),
queryFn: () => fetchUser(id),
})),
});
const isLoading = userQueries.some((query) => query.isLoading);
const users = userQueries
.map((query) => query.data)
.filter((user): user is User => user !== undefined);
if (isLoading) return <div>Loading users...</div>;
return (
<div>
{users.map((user) => (
<UserCard key={user.id} user={user} />
))}
</div>
);
}
Pattern 9: Type-Safe Error Handling
Different error types for different scenarios.
// Define error types
class ApiError extends Error {
constructor(
message: string,
public status: number,
public code?: string
) {
super(message);
this.name = 'ApiError';
}
}
class NetworkError extends Error {
constructor(message: string) {
super(message);
this.name = 'NetworkError';
}
}
type AppError = ApiError | NetworkError;
// Enhanced fetch function
async function fetchUser(userId: string): Promise<User> {
try {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new ApiError(
'Failed to fetch user',
response.status,
response.statusText
);
}
return response.json();
} catch (error) {
if (error instanceof ApiError) {
throw error;
}
throw new NetworkError('Network request failed');
}
}
// Usage with typed errors
function UserProfile({ userId }: { userId: string }) {
const { data, error } = useQuery<User, AppError>({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
});
if (error) {
if (error instanceof ApiError) {
if (error.status === 404) {
return <div>User not found</div>;
}
if (error.status === 403) {
return <div>Access denied</div>;
}
return <div>Server error: {error.message}</div>;
}
if (error instanceof NetworkError) {
return <div>Network error. Please check your connection.</div>;
}
}
return <div>{data?.name}</div>;
}
Pattern 10: Cache Manipulation
Directly read and update the cache.
function useUserActions(userId: string) {
const queryClient = useQueryClient();
const updateUserInCache = (updates: Partial<User>) => {
queryClient.setQueryData<User>(
queryKeys.users.detail(userId),
(old) => old ? { ...old, ...updates } : old
);
};
const addUserToList = (user: User) => {
queryClient.setQueryData<User[]>(
queryKeys.users.lists(),
(old) => old ? [...old, user] : [user]
);
};
const removeUserFromCache = () => {
queryClient.removeQueries({ queryKey: queryKeys.users.detail(userId) });
};
const prefetchUser = () => {
queryClient.prefetchQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
});
};
return {
updateUserInCache,
addUserToList,
removeUserFromCache,
prefetchUser,
};
}
// Usage - optimistic update with cache manipulation
function UserCard({ user }: { user: User }) {
const { updateUserInCache } = useUserActions(user.id);
const updateUser = useUpdateUser();
const handleNameChange = (newName: string) => {
// Immediately update cache
updateUserInCache({ name: newName });
// Then sync with server
updateUser.mutate(
{ userId: user.id, data: { name: newName } },
{
onError: () => {
// Rollback on error (refetch)
queryClient.invalidateQueries({
queryKey: queryKeys.users.detail(user.id),
});
},
}
);
};
return (
<div>
<h2>{user.name}</h2>
<button onClick={() => handleNameChange('New Name')}>
Update Name
</button>
</div>
);
}
Pattern 11: Polling and Refetching
Keep data fresh automatically.
// Polling - refetch at interval
function useRealtimeData() {
return useQuery({
queryKey: ['realtime-data'],
queryFn: fetchRealtimeData,
refetchInterval: 1000 * 5, // Every 5 seconds
refetchIntervalInBackground: true, // Even when tab is not focused
});
}
// Refetch on window focus
function useUserProfile(userId: string) {
return useQuery({
queryKey: queryKeys.users.detail(userId),
queryFn: () => fetchUser(userId),
refetchOnWindowFocus: true, // Default behavior
staleTime: 1000 * 60 * 5, // Data is fresh for 5 minutes
});
}
// Manual refetch
function UserProfileWithRefresh({ userId }: { userId: string }) {
const { data, refetch, isFetching } = useUser(userId);
return (
<div>
<button onClick={() => refetch()} disabled={isFetching}>
{isFetching ? 'Refreshing...' : 'Refresh'}
</button>
<div>{data?.name}</div>
</div>
);
}
Pattern 12: Suspense Mode
Use React Suspense for loading states.
// Enable suspense in query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
suspense: true,
},
},
});
// Component automatically suspends while loading
function UserProfile({ userId }: { userId: string }) {
const { data } = useUser(userId, { suspense: true });
// No loading check needed - component suspends
return <div>{data.name}</div>;
}
// Parent component handles loading
function App() {
return (
<Suspense fallback={<div>Loading user...</div>}>
<UserProfile userId="123" />
</Suspense>
);
}
// Error boundary for errors
class ErrorBoundary extends React.Component<
{ children: React.ReactNode },
{ hasError: boolean }
> {
constructor(props: { children: React.ReactNode }) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError() {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return <div>Something went wrong</div>;
}
return this.props.children;
}
}
function AppWithSuspense() {
return (
<ErrorBoundary>
<Suspense fallback={<div>Loading...</div>}>
<UserProfile userId="123" />
</Suspense>
</ErrorBoundary>
);
}
Pattern 13: Request Deduplication
React Query automatically deduplicates requests.
function Dashboard() {
return (
<div>
<UserWidget userId="123" />
<UserPosts userId="123" />
<UserStats userId="123" />
</div>
);
}
function UserWidget({ userId }: { userId: string }) {
const { data } = useUser(userId);
return <div>{data?.name}</div>;
}
function UserPosts({ userId }: { userId: string }) {
const { data: user } = useUser(userId); // Same query key!
// ...
}
function UserStats({ userId }: { userId: string }) {
const { data: user } = useUser(userId); // Same query key!
// ...
}
// Only ONE request is made for user "123"
// All three components share the same data
Pattern 14: Mutation Side Effects
Run side effects after mutations.
function useDeleteUser() {
const queryClient = useQueryClient();
const navigate = useNavigate();
return useMutation({
mutationFn: async (userId: string) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'DELETE',
});
if (!response.ok) throw new Error('Failed to delete user');
return userId;
},
onMutate: async (userId) => {
// Show optimistic UI
toast.info('Deleting user...');
},
onSuccess: (deletedUserId) => {
// Remove from cache
queryClient.removeQueries({
queryKey: queryKeys.users.detail(deletedUserId),
});
// Update lists
queryClient.setQueryData<User[]>(
queryKeys.users.lists(),
(old) => old?.filter((user) => user.id !== deletedUserId)
);
// Navigate away
navigate('/users');
// Show success message
toast.success('User deleted successfully');
},
onError: (error) => {
toast.error(`Failed to delete user: ${error.message}`);
},
});
}
Pattern 15: Global Loading and Error States
Track loading and error states globally.
import { useIsFetching, useIsMutating } from '@tanstack/react-query';
function GlobalLoadingIndicator() {
const isFetching = useIsFetching();
const isMutating = useIsMutating();
if (isFetching === 0 && isMutating === 0) return null;
return (
<div className="global-loading-indicator">
{isFetching > 0 && <span>Loading data...</span>}
{isMutating > 0 && <span>Saving changes...</span>}
</div>
);
}
// Specific query loading
function SpecificLoadingIndicator() {
const isFetchingUsers = useIsFetching({ queryKey: queryKeys.users.all });
if (isFetchingUsers === 0) return null;
return <div>Loading users...</div>;
}
Best Practices Summary
1. Query Keys
- Use query key factory pattern
- Make keys hierarchical
- Use
as constfor type safety
2. Error Handling
- Define custom error types
- Handle errors at component level
- Use error boundaries for catastrophic failures
3. Caching Strategy
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 10, // 10 minutes (formerly cacheTime)
retry: 1,
refetchOnWindowFocus: true,
},
},
});
4. Mutations
- Always invalidate related queries
- Use optimistic updates for better UX
- Provide loading and error feedback
5. Type Safety
- Define interfaces for all data shapes
- Use generics in custom hooks
- Let TypeScript infer when possible
Common Mistakes to Avoid
❌ Mistake 1: Not Using Query Keys Correctly
// ❌ Bad - string keys, no caching benefits
useQuery({ queryKey: ['users'], queryFn: () => fetchUser(userId) });
// ✅ Good - include dynamic values in key
useQuery({ queryKey: ['users', userId], queryFn: () => fetchUser(userId) });
❌ Mistake 2: Not Invalidating After Mutations
// ❌ Bad - cache is stale
const mutation = useMutation({
mutationFn: createUser,
// Missing onSuccess!
});
// ✅ Good - cache stays fresh
const mutation = useMutation({
mutationFn: createUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.lists() });
},
});
❌ Mistake 3: Not Handling Loading States
// ❌ Bad - runtime error
const { data } = useUser(userId);
return <div>{data.name}</div>; // data might be undefined!
// ✅ Good - handle loading
const { data, isLoading } = useUser(userId);
if (isLoading) return <div>Loading...</div>;
return <div>{data.name}</div>;
❌ Mistake 4: Overusing enabled: false
// ❌ Bad - defeats automatic refetching
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
enabled: false, // Why disable automatic fetching?
});
// ✅ Good - let React Query handle it
useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
// React Query will refetch when needed
});
The React Query + TypeScript Advantage
Before React Query:
- 200 lines of state management code
- Manual caching logic
- Stale data bugs
- Race condition bugs
- Complex loading states
After React Query with TypeScript:
- 20 lines of declarative queries
- Automatic caching
- Always fresh data
- No race conditions
- Perfect type inference
React Query handles the complexity. TypeScript ensures correctness. Together, they make async state management nearly invisible.
Conclusion
React Query + TypeScript isn't just about type safety. It's about:
✅ Eliminating boilerplate - No more manual state management
✅ Automatic caching - Requests deduplicated, data shared
✅ Perfect types - IntelliSense knows your data shape
✅ Optimistic updates - Instant UI, eventual consistency
✅ Developer experience - Devtools show you everything
The learning curve is real, but the payoff is immediate. Start with basic queries, add mutations, then explore advanced patterns.
Your components will shrink. Your code will be more reliable. Your users will have a better experience.
And you'll never go back to managing async state manually.
What async state patterns are you struggling with? Share in the comments and I'll add solutions!
Top comments (0)