State Management with React Query 🔄

Master data fetching and state synchronization in CrittrHavens. A comprehensive guide to React Query patterns and best practices.

Dynamic 3D render of abstract geometric data paths with colorful blocks representing data flow

Understanding React Query

Why React Query revolutionizes state management.

What is React Query?

TanStack Query (formerly React Query):

  • Server state management library
  • Automatic caching and synchronization
  • Background refetching
  • Optimistic updates
  • Request deduplication
  • Pagination support

Server vs Client State

State Categories:

// Server State (managed by React Query)
- User data from database
- Crittr information
- Care logs
- Inventory items

// Client State (managed by React)
- UI state (modals, tooltips)
- Form inputs
- Navigation state
- User preferences

Core Concepts

Key Components:

  • Queries: Fetch and cache data
  • Mutations: Modify server data
  • Query Client: Cache manager
  • Query Keys: Cache identifiers
  • Query Functions: Data fetchers

Query Patterns

Fetching data the React Query way.

Basic Query

Simple Data Fetching:

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

function useCrittrs(havenId: string) {
  return useQuery({
    queryKey: ['crittrs', havenId],
    queryFn: async () => {
      const { data, error } = await supabase
        .from('crittrs')
        .select('*')
        .eq('haven_id', havenId)
        .order('name');
      
      if (error) throw error;
      return data;
    },
    enabled: !!havenId, // Only run if havenId exists
  });
}

// Using the query
function CrittrList({ havenId }) {
  const { data: crittrs, isLoading, error } = useCrittrs(havenId);
  
  if (isLoading) return <Skeleton />;
  if (error) return <ErrorMessage error={error} />;
  
  return crittrs.map(crittr => <CrittrCard key={crittr.id} {...crittr} />);
}

Query Options

Advanced Configuration:

useQuery({
  queryKey: ['logs', crittrId],
  queryFn: fetchLogs,
  
  // Caching
  staleTime: 5 * 60 * 1000,      // Consider data fresh for 5 minutes
  gcTime: 10 * 60 * 1000,        // Keep in cache for 10 minutes
  
  // Refetching
  refetchInterval: 60 * 1000,     // Refetch every minute
  refetchOnWindowFocus: true,     // Refetch when tab gains focus
  refetchOnReconnect: true,        // Refetch when reconnecting
  
  // Retries
  retry: 3,                        // Retry failed requests 3 times
  retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
  
  // Initial data
  initialData: [],                 // Show empty list initially
  placeholderData: previousData,   // Keep showing old data while fetching
});

Dependent Queries

Sequential Fetching:

function useCrittrWithLogs(crittrId: string) {
  // First query: get crittr
  const { data: crittr } = useQuery({
    queryKey: ['crittr', crittrId],
    queryFn: () => fetchCrittr(crittrId),
  });
  
  // Second query: get logs (depends on crittr)
  const { data: logs } = useQuery({
    queryKey: ['logs', crittrId],
    queryFn: () => fetchLogs(crittrId),
    enabled: !!crittr, // Only run after crittr is loaded
  });
  
  return { crittr, logs };
}

Parallel Queries

Concurrent Fetching:

function useHavenData(havenId: string) {
  const queries = useQueries({
    queries: [
      {
        queryKey: ['haven', havenId],
        queryFn: () => fetchHaven(havenId),
      },
      {
        queryKey: ['crittrs', havenId],
        queryFn: () => fetchCrittrs(havenId),
      },
      {
        queryKey: ['tasks', havenId],
        queryFn: () => fetchTasks(havenId),
      },
    ],
  });
  
  const [havenQuery, crittrsQuery, tasksQuery] = queries;
  
  return {
    haven: havenQuery.data,
    crittrs: crittrsQuery.data,
    tasks: tasksQuery.data,
    isLoading: queries.some(q => q.isLoading),
  };
}

Mutation Patterns

Modifying server data with confidence.

Basic Mutation

Create, Update, Delete:

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

function useCreateCrittr() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: async (newCrittr: CrittrInput) => {
      const { data, error } = await supabase
        .from('crittrs')
        .insert(newCrittr)
        .select()
        .single();
      
      if (error) throw error;
      return data;
    },
    
    onSuccess: (data, variables) => {
      // Invalidate and refetch
      queryClient.invalidateQueries({ 
        queryKey: ['crittrs', variables.haven_id] 
      });
      
      toast.success(`${data.name} added successfully!`);
    },
    
    onError: (error) => {
      toast.error('Failed to add crittr');
      console.error(error);
    },
  });
}

// Using the mutation
function AddCrittrForm() {
  const createCrittr = useCreateCrittr();
  
  const handleSubmit = (formData) => {
    createCrittr.mutate(formData);
  };
  
  return (
    <form onSubmit={handleSubmit}>
      {/* form fields */}
      <Button 
        type="submit" 
        disabled={createCrittr.isPending}
      >
        {createCrittr.isPending ? 'Adding...' : 'Add Crittr'}
      </Button>
    </form>
  );
}

Mutation Callbacks

Lifecycle Hooks:

useMutation({
  mutationFn: updateCrittr,
  
  // Before mutation starts
  onMutate: async (newData) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries({ queryKey: ['crittr', id] });
    
    // Snapshot previous value
    const previousCrittr = queryClient.getQueryData(['crittr', id]);
    
    // Return context for rollback
    return { previousCrittr };
  },
  
  // On success
  onSuccess: (data, variables, context) => {
    // Update cache with new data
    queryClient.setQueryData(['crittr', id], data);
  },
  
  // On error
  onError: (err, variables, context) => {
    // Rollback to previous value
    if (context?.previousCrittr) {
      queryClient.setQueryData(['crittr', id], context.previousCrittr);
    }
  },
  
  // Always runs
  onSettled: () => {
    // Invalidate to ensure consistency
    queryClient.invalidateQueries({ queryKey: ['crittr', id] });
  },
});

Optimistic Updates

Instant UI feedback for better UX.

Optimistic Update Pattern

Update UI Before Server Response:

function useToggleFavorite() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: toggleFavorite,
    
    onMutate: async ({ crittrId, isFavorite }) => {
      // Cancel queries
      await queryClient.cancelQueries(['crittr', crittrId]);
      
      // Get current data
      const previousCrittr = queryClient.getQueryData(['crittr', crittrId]);
      
      // Optimistically update
      queryClient.setQueryData(['crittr', crittrId], old => ({
        ...old,
        is_favorite: isFavorite,
      }));
      
      return { previousCrittr };
    },
    
    onError: (err, variables, context) => {
      // Rollback on error
      queryClient.setQueryData(
        ['crittr', variables.crittrId],
        context.previousCrittr
      );
    },
    
    onSettled: () => {
      // Always refetch to ensure consistency
      queryClient.invalidateQueries(['crittr']);
    },
  });
}

Optimistic List Updates

Adding to Lists:

function useAddLog() {
  const queryClient = useQueryClient();
  
  return useMutation({
    mutationFn: createLog,
    
    onMutate: async (newLog) => {
      await queryClient.cancelQueries(['logs', newLog.crittr_id]);
      
      const previousLogs = queryClient.getQueryData(['logs', newLog.crittr_id]);
      
      // Add optimistically to list
      queryClient.setQueryData(['logs', newLog.crittr_id], old => [
        { ...newLog, id: 'temp-' + Date.now() }, // Temporary ID
        ...old,
      ]);
      
      return { previousLogs };
    },
    
    onSuccess: (data, variables) => {
      // Replace temp item with real data
      queryClient.setQueryData(['logs', variables.crittr_id], old =>
        old.map(log => 
          log.id.startsWith('temp-') ? data : log
        )
      );
    },
  });
}

Cache Management

Controlling React Query's cache.

Query Invalidation

Force Refetch:

// Invalidate specific query
queryClient.invalidateQueries({ queryKey: ['crittrs'] });

// Invalidate with exact match
queryClient.invalidateQueries({ 
  queryKey: ['crittrs', havenId],
  exact: true,
});

// Invalidate multiple queries
queryClient.invalidateQueries({ 
  predicate: query => query.queryKey[0] === 'crittrs',
});

// Invalidate and wait for refetch
await queryClient.invalidateQueries(
  { queryKey: ['crittrs'] },
  { throwOnError: true }
);

Direct Cache Updates

Manual Cache Manipulation:

// Set query data
queryClient.setQueryData(['crittr', id], newCrittr);

// Update query data
queryClient.setQueryData(['crittr', id], old => ({
  ...old,
  name: 'New Name',
}));

// Get query data
const crittr = queryClient.getQueryData(['crittr', id]);

// Remove from cache
queryClient.removeQueries({ queryKey: ['crittr', id] });

// Reset to initial data
queryClient.resetQueries({ queryKey: ['crittrs'] });

Cache Persistence

Persist Cache to Storage:

import { persistQueryClient } from '@tanstack/react-query-persist-client';

// Persist to localStorage
persistQueryClient({
  queryClient,
  persister: createSyncStoragePersister({
    storage: window.localStorage,
  }),
  maxAge: 1000 * 60 * 60 * 24, // 24 hours
});

Infinite Queries

Pagination and infinite scrolling.

Infinite Scroll Pattern

Load More Implementation:

function useInfiniteLogs(crittrId: string) {
  return useInfiniteQuery({
    queryKey: ['logs', crittrId, 'infinite'],
    queryFn: async ({ pageParam = 0 }) => {
      const { data, error } = await supabase
        .from('logs')
        .select('*')
        .eq('crittr_id', crittrId)
        .order('created_at', { ascending: false })
        .range(pageParam, pageParam + 19); // 20 items per page
      
      if (error) throw error;
      return data;
    },
    
    getNextPageParam: (lastPage, allPages) => {
      if (lastPage.length < 20) return undefined;
      return allPages.length * 20;
    },
    
    initialPageParam: 0,
  });
}

// Using infinite query
function LogList({ crittrId }) {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
  } = useInfiniteLogs(crittrId);
  
  return (
    <>
      {data?.pages.map((page, i) => (
        <div key={i}>
          {page.map(log => <LogCard key={log.id} {...log} />)}
        
      ))}
      
      {hasNextPage && (
        <Button
          onClick={() => fetchNextPage()}
          disabled={isFetchingNextPage}
        >
          {isFetchingNextPage ? 'Loading...' : 'Load More'}
        </Button>
      )}
    </>
  );
}

Intersection Observer

Auto-Load on Scroll:

function InfiniteLogList({ crittrId }) {
  const { data, fetchNextPage, hasNextPage } = useInfiniteLogs(crittrId);
  const loadMoreRef = useRef(null);
  
  useEffect(() => {
    const observer = new IntersectionObserver(
      entries => {
        if (entries[0].isIntersecting && hasNextPage) {
          fetchNextPage();
        }
      },
      { threshold: 1.0 }
    );
    
    if (loadMoreRef.current) {
      observer.observe(loadMoreRef.current);
    }
    
    return () => observer.disconnect();
  }, [hasNextPage, fetchNextPage]);
  
  return (
    <>
      {/* Render logs */}
      <div ref={loadMoreRef} />
    </>
  );
}

Error Handling

Graceful error management.

Query Error Handling

Error States:

function CrittrDetail({ id }) {
  const { data, error, isError, refetch } = useQuery({
    queryKey: ['crittr', id],
    queryFn: () => fetchCrittr(id),
    retry: (failureCount, error) => {
      // Don't retry on 404
      if (error.status === 404) return false;
      // Retry up to 3 times for other errors
      return failureCount < 3;
    },
  });
  
  if (isError) {
    return (
      <ErrorBoundary
        error={error}
        onRetry={refetch}
        fallback={
          error.status === 404 
            ? <NotFound /> 
            : <ErrorMessage error={error} />
        }
      />
    );
  }
  
  return <CrittrInfo {...data} />;
}

Global Error Handling

Query Client Configuration:

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: (failureCount, error) => {
        if (error.status === 404) return false;
        return failureCount < 3;
      },
      onError: (error) => {
        console.error('Query error:', error);
        toast.error('Failed to fetch data');
      },
    },
    mutations: {
      onError: (error) => {
        console.error('Mutation error:', error);
        toast.error('Operation failed');
      },
    },
  },
});

Custom Hooks

Building reusable React Query hooks.

Hook Patterns

Encapsulated Logic:

// Complete CRUD hook
export function useCrittrManagement(havenId: string) {
  const queryClient = useQueryClient();
  
  // Read
  const crittrsQuery = useQuery({
    queryKey: ['crittrs', havenId],
    queryFn: () => fetchCrittrs(havenId),
  });
  
  // Create
  const createMutation = useMutation({
    mutationFn: createCrittr,
    onSuccess: () => {
      queryClient.invalidateQueries(['crittrs', havenId]);
    },
  });
  
  // Update
  const updateMutation = useMutation({
    mutationFn: updateCrittr,
    onSuccess: (data) => {
      queryClient.setQueryData(['crittr', data.id], data);
      queryClient.invalidateQueries(['crittrs', havenId]);
    },
  });
  
  // Delete
  const deleteMutation = useMutation({
    mutationFn: deleteCrittr,
    onSuccess: () => {
      queryClient.invalidateQueries(['crittrs', havenId]);
    },
  });
  
  return {
    crittrs: crittrsQuery.data,
    isLoading: crittrsQuery.isLoading,
    createCrittr: createMutation.mutate,
    updateCrittr: updateMutation.mutate,
    deleteCrittr: deleteMutation.mutate,
    isCreating: createMutation.isPending,
    isUpdating: updateMutation.isPending,
    isDeleting: deleteMutation.isPending,
  };
}

Shared Query Options

Reusable Configurations:

// Shared options
const crittrQueryOptions = {
  staleTime: 5 * 60 * 1000,
  gcTime: 10 * 60 * 1000,
};

// Use in multiple hooks
export function useCrittr(id: string) {
  return useQuery({
    queryKey: ['crittr', id],
    queryFn: () => fetchCrittr(id),
    ...crittrQueryOptions,
  });
}

export function useCrittrs(havenId: string) {
  return useQuery({
    queryKey: ['crittrs', havenId],
    queryFn: () => fetchCrittrs(havenId),
    ...crittrQueryOptions,
  });
}

Performance Optimization

Making React Query blazing fast.

Query Optimization

Performance Tips:

// 1. Select only needed fields
const { data } = useQuery({
  queryKey: ['crittrs', 'minimal'],
  queryFn: () => supabase
    .from('crittrs')
    .select('id, name, species'), // Only what you need
});

// 2. Use placeholderData for instant UI
const { data } = useQuery({
  queryKey: ['crittr', id],
  queryFn: fetchCrittr,
  placeholderData: () => {
    // Use data from list if available
    return queryClient
      .getQueryData(['crittrs'])
      ?.find(c => c.id === id);
  },
});

// 3. Prefetch on hover
const prefetchCrittr = (id: string) => {
  queryClient.prefetchQuery({
    queryKey: ['crittr', id],
    queryFn: () => fetchCrittr(id),
    staleTime: 10 * 1000,
  });
};

// 4. Background refetching
useQuery({
  queryKey: ['stats'],
  queryFn: fetchStats,
  refetchInterval: 60 * 1000, // Refetch every minute
  refetchIntervalInBackground: true,
});

Bundle Size

Code Splitting:

// Lazy load heavy queries
const HeavyComponent = lazy(() => import('./HeavyComponent'));

// Conditional query loading
const { data } = useQuery({
  queryKey: ['expensive-data'],
  queryFn: fetchExpensiveData,
  enabled: userWantsToSeeThis, // Only load when needed
});

DevTools Usage

Debugging with React Query DevTools.

Setup DevTools

Development Only:

// App.tsx
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {/* Your app */}
      {process.env.NODE_ENV === 'development' && (
        <ReactQueryDevtools initialIsOpen={false} />
      )}
    </QueryClientProvider>
  );
}

DevTools Features

Debugging Tools:

  • View all queries and their states
  • Inspect cache data
  • Trigger refetches manually
  • Clear cache
  • Toggle query states
  • Monitor network requests
  • Track query timings

Best Practices

React Query patterns for success.

Query Key Conventions

Consistent Keys:

// Hierarchical keys
['crittrs']                    // All crittrs
['crittrs', havenId]          // Crittrs in haven
['crittrs', havenId, crittrId] // Specific crittr

// Include filters in keys
['crittrs', { haven: havenId, species: 'python' }]

// Version your keys
['v1', 'crittrs']

Error Boundaries

Catch Query Errors:

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

function App() {
  return (
    <QueryErrorResetBoundary>
      {({ reset }) => (
        <ErrorBoundary
          onReset={reset}
          fallbackRender={({ resetErrorBoundary }) => (
            <div>
              Something went wrong
              <button onClick={resetErrorBoundary}>Retry</button>
            
          )}
        >
          <Routes />
        </ErrorBoundary>
      )}
    </QueryErrorResetBoundary>
  );
}

Common Patterns

Solutions for typical scenarios.

Search with Debounce

function useSearch() {
  const [search, setSearch] = useState('');
  const debouncedSearch = useDebounce(search, 500);
  
  return useQuery({
    queryKey: ['search', debouncedSearch],
    queryFn: () => searchCrittrs(debouncedSearch),
    enabled: debouncedSearch.length > 2,
  });
}

Polling for Updates

useQuery({
  queryKey: ['notifications'],
  queryFn: fetchNotifications,
  refetchInterval: 5000, // Poll every 5 seconds
  refetchIntervalInBackground: false, // Stop when tab is hidden
});

Conditional Fetching

const { data: user } = useUser();

const { data: profile } = useQuery({
  queryKey: ['profile', user?.id],
  queryFn: () => fetchProfile(user.id),
  enabled: !!user?.id, // Only fetch when user exists
});

Next Steps

Continue mastering state management.


Synchronizing your reptile data with the elegance of React Query. 🦎