DEV Community

Tarun Moorjani
Tarun Moorjani

Posted on

React Query + TypeScript: Async State Management Perfected

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
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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!
Enter fullscreen mode Exit fullscreen mode

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) });
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

What happens:

  1. User clicks checkbox → UI updates immediately
  2. Request sent to server in background
  3. If server confirms → keep the optimistic update
  4. If server fails → rollback to previous state
  5. 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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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}`);
    },
  });
}
Enter fullscreen mode Exit fullscreen mode

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>;
}
Enter fullscreen mode Exit fullscreen mode

Best Practices Summary

1. Query Keys

  • Use query key factory pattern
  • Make keys hierarchical
  • Use as const for 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,
    },
  },
});
Enter fullscreen mode Exit fullscreen mode

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) });
Enter fullscreen mode Exit fullscreen mode

❌ 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() });
  },
});
Enter fullscreen mode Exit fullscreen mode

❌ 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>;
Enter fullscreen mode Exit fullscreen mode

❌ 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
});
Enter fullscreen mode Exit fullscreen mode

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)