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

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. 🦎