skills/masanao-ohba/claude-manifests/react-query-patterns

react-query-patterns

SKILL.md

TanStack React Query Patterns

Setup

Query Client

// app/providers.tsx
'use client';

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

export function Providers({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(
    () =>
      new QueryClient({
        defaultOptions: {
          queries: {
            staleTime: 60 * 1000, // 1 minute
            gcTime: 5 * 60 * 1000, // 5 minutes (formerly cacheTime)
            retry: 1,
            refetchOnWindowFocus: false,
          },
        },
      })
  );

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}

Queries

Basic Query

Fetch data with automatic caching:

'use client';

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

interface User {
  id: string;
  name: string;
  email: string;
}

export function UserProfile({ userId }: { userId: string }) {
  const { data, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: async () => {
      const response = await fetch(`/api/users/${userId}`);
      if (!response.ok) throw new Error('Failed to fetch user');
      return response.json() as Promise<User>;
    },
  });

  if (isLoading) return <LoadingSkeleton />;
  if (error) return <ErrorMessage error={error} />;

  return <div>{data.name}</div>;
}

Query with Options

const { data, isLoading, error, refetch } = useQuery({
  queryKey: ['posts', { page, filter }],
  queryFn: () => fetchPosts(page, filter),
  enabled: !!userId, // Only run if userId exists
  staleTime: 5 * 60 * 1000, // Data fresh for 5 minutes
  gcTime: 10 * 60 * 1000, // Keep in cache for 10 minutes
  retry: 3, // Retry failed requests 3 times
  refetchInterval: 30 * 1000, // Refetch every 30 seconds
  refetchOnWindowFocus: true, // Refetch when window regains focus
});

Query Keys

Structure

Hierarchical query key organization:

Pattern Example
Simple ['users']
With ID ['users', userId]
With params ['posts', { page, filter }]
Nested ['users', userId, 'posts']

Best Practices

  • Use arrays for all query keys
  • Order from general to specific
  • Include all variables that affect the query
  • Use objects for multiple parameters
  • Keep keys consistent across the app

Query Key Factory

// Query key organization
const queryKeys = {
  users: ['users'] as const,
  user: (id: string) => ['users', id] as const,
  userPosts: (id: string) => ['users', id, 'posts'] as const,
  posts: {
    all: ['posts'] as const,
    lists: () => ['posts', 'list'] as const,
    list: (filters: PostFilters) => ['posts', 'list', filters] as const,
    detail: (id: string) => ['posts', id] as const,
  },
};

// Usage
const { data } = useQuery({
  queryKey: queryKeys.user(userId),
  queryFn: () => fetchUser(userId),
});

Mutations

Basic Mutation

Modify server data:

import { useMutation, useQueryClient } from '@tanstack/react-query';

export function CreatePostForm() {
  const queryClient = useQueryClient();

  const mutation = useMutation({
    mutationFn: async (newPost: NewPost) => {
      const response = await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newPost),
      });
      if (!response.ok) throw new Error('Failed to create post');
      return response.json();
    },
    onSuccess: () => {
      // Invalidate and refetch posts query
      queryClient.invalidateQueries({ queryKey: ['posts'] });
    },
  });

  const handleSubmit = (data: NewPost) => {
    mutation.mutate(data);
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      {mutation.isPending && <p>Creating post...</p>}
      {mutation.isError && <p>Error: {mutation.error.message}</p>}
      {mutation.isSuccess && <p>Post created!</p>}
    </form>
  );
}

Optimistic Updates

Update UI immediately before server responds:

const mutation = useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // Snapshot current value
    const previousTodos = queryClient.getQueryData(['todos']);

    // Optimistically update
    queryClient.setQueryData(['todos'], (old: Todo[]) => {
      return old.map((todo) =>
        todo.id === newTodo.id ? newTodo : todo
      );
    });

    // Return context with snapshot
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // Rollback on error
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    // Refetch after error or success
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
});

Mutation with Invalidation

const mutation = useMutation({
  mutationFn: deletePost,
  onSuccess: (_, deletedPostId) => {
    // Invalidate list query
    queryClient.invalidateQueries({ queryKey: ['posts', 'list'] });

    // Remove deleted post from cache
    queryClient.removeQueries({ queryKey: ['posts', deletedPostId] });

    // Or update cache manually
    queryClient.setQueryData(['posts', 'list'], (old: Post[]) => {
      return old.filter((post) => post.id !== deletedPostId);
    });
  },
});

Infinite Queries

Load more data as user scrolls:

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

export function InfinitePostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
  } = useInfiniteQuery({
    queryKey: ['posts', 'infinite'],
    queryFn: async ({ pageParam }) => {
      const response = await fetch(`/api/posts?page=${pageParam}`);
      return response.json();
    },
    initialPageParam: 1,
    getNextPageParam: (lastPage, allPages) => {
      return lastPage.hasMore ? allPages.length + 1 : undefined;
    },
  });

  if (isLoading) return <LoadingSkeleton />;

  return (
    <div>
      {data.pages.map((page, i) => (
        <div key={i}>
          {page.posts.map((post) => (
            <PostCard key={post.id} post={post} />
          ))}
        </div>
      ))}
      {hasNextPage && (
        <button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </button>
      )}
    </div>
  );
}

Prefetching

Hover Prefetch

Prefetch data on hover for instant navigation:

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

export function PostLink({ postId }: { postId: string }) {
  const queryClient = useQueryClient();

  const prefetchPost = () => {
    queryClient.prefetchQuery({
      queryKey: ['posts', postId],
      queryFn: () => fetchPost(postId),
      staleTime: 60 * 1000, // Keep for 1 minute
    });
  };

  return (
    <Link
      href={`/posts/${postId}`}
      onMouseEnter={prefetchPost}
      onTouchStart={prefetchPost}
    >
      View Post
    </Link>
  );
}

Page Prefetch

const { data } = useQuery({
  queryKey: ['posts', page],
  queryFn: () => fetchPosts(page),
});

// Prefetch next page
useEffect(() => {
  if (data?.hasMore) {
    queryClient.prefetchQuery({
      queryKey: ['posts', page + 1],
      queryFn: () => fetchPosts(page + 1),
    });
  }
}, [data, page, queryClient]);

Dependent Queries

Query depends on result of previous query:

// First query
const { data: user } = useQuery({
  queryKey: ['user', userId],
  queryFn: () => fetchUser(userId),
});

// Second query depends on first
const { data: projects } = useQuery({
  queryKey: ['projects', user?.id],
  queryFn: () => fetchUserProjects(user.id),
  enabled: !!user, // Only run when user exists
});

Parallel Queries

Multiple Independent Queries

export function Dashboard() {
  const users = useQuery({
    queryKey: ['users'],
    queryFn: fetchUsers,
  });

  const posts = useQuery({
    queryKey: ['posts'],
    queryFn: fetchPosts,
  });

  const stats = useQuery({
    queryKey: ['stats'],
    queryFn: fetchStats,
  });

  if (users.isLoading || posts.isLoading || stats.isLoading) {
    return <LoadingSkeleton />;
  }

  return (
    <div>
      <UserList users={users.data} />
      <PostList posts={posts.data} />
      <Stats data={stats.data} />
    </div>
  );
}

useQueries for Dynamic Queries

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

export function MultiUserView({ userIds }: { userIds: string[] }) {
  const userQueries = useQueries({
    queries: userIds.map((id) => ({
      queryKey: ['user', id],
      queryFn: () => fetchUser(id),
    })),
  });

  const isLoading = userQueries.some((query) => query.isLoading);
  const users = userQueries.map((query) => query.data);

  if (isLoading) return <LoadingSkeleton />;

  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

Error Handling

Retry Logic

const { data, error } = useQuery({
  queryKey: ['posts'],
  queryFn: fetchPosts,
  retry: (failureCount, error) => {
    // Don't retry on 404
    if (error.status === 404) return false;
    // Retry up to 3 times
    return failureCount < 3;
  },
  retryDelay: (attemptIndex) => {
    // Exponential backoff: 1s, 2s, 4s
    return Math.min(1000 * 2 ** attemptIndex, 30000);
  },
});

Error Boundaries

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

export function CriticalData() {
  const { showBoundary } = useErrorBoundary();

  const { data } = useQuery({
    queryKey: ['critical'],
    queryFn: fetchCriticalData,
    throwOnError: true, // Throw errors to Error Boundary
  });

  return <div>{/* render data */}</div>;
}

Best Practices

Do

  • Use query keys as array of dependencies
  • Invalidate queries after mutations
  • Prefetch on hover for better UX
  • Use staleTime to reduce unnecessary refetches
  • Implement optimistic updates for instant feedback
  • Use enabled option for dependent queries
  • Keep query functions pure and reusable

Don't

  • Don't use query keys as strings (use arrays)
  • Don't mutate query data directly
  • Don't forget to handle loading and error states
  • Don't set staleTime too low (causes excessive requests)
  • Don't invalidate all queries (be specific)
  • Don't put business logic in queryFn (use services)

Performance

Optimization

  • Set appropriate staleTime (avoid unnecessary refetches)
  • Use gcTime to control cache memory usage
  • Implement pagination or infinite queries for large lists
  • Prefetch predictable user navigation
  • Use select option to subscribe to only needed data

Monitoring

  • Enable React Query Devtools in development
  • Monitor network requests in DevTools
  • Check query cache size regularly
  • Measure query execution time
Weekly Installs
9
GitHub Stars
2
First Seen
Jan 29, 2026
Installed on
cursor8
opencode7
gemini-cli7
github-copilot7
codex7
claude-code5