Environment: React Native 0.76+ · @tanstack/react-query v5 · TypeScript
I was building a React Native app where users could interact with the same data from multiple screens. Everything seemed fine during early testing until I noticed something deeply frustrating: updating data on one screen didn't reflect on another until I manually refreshed it.
Sound familiar? Here's how I diagnosed it, fixed it with 3 focused React Query patterns, and what I learned along the way.
The Problem
The app had three screens showing overlapping data:
- Screen A : Shows a list of items
- Screen B : Shows the same items in a different view
- Screen C : Shows item details with edit capability
When a user marked an item complete on Screen A, navigating to Screen B still showed it as incomplete. Users had to pull-to-refresh on every screen after every action. The app felt broken, because it was.
Root Cause: Isolated State
After some digging, the culprit was clear. Each screen used its own hook with independent useState:
import { useState } from 'react';
import { api } from '../lib/api';
// Screen A hook
function useListA() {
const [items, setItems] = useState([]);
const markComplete = async (id: string) => {
await api.markComplete(id);
setItems(prev => prev.map(item =>
item.id === id ? { ...item, completed: true } : item
));
// Other screens have no idea this happened
};
}
// Screen B hook — completely separate state
function useListB() {
const [items, setItems] = useState([]);
// No connection to Screen A's state whatsoever
}
The architecture looked like this:
Screen A Screen B Screen C
│ │ │
▼ ▼ ▼
useState useState useState
(isolated) (isolated) (isolated)
│ │ │
└─────────────────┴─────────────────┘
│
▼
Backend API
Each screen fetched and managed data independently. When one mutated data, the others had no idea. This isn't a bug, it's the natural consequence of uncoordinated local state managing shared server data.
Why Not Just Use Zustand?
This question comes up a lot, and it's worth addressing directly before diving into the solution.
Zustand is a client state manager. It's excellent for data that lives purely inside your app UI state, filter selections, modal toggles, user preferences. It has no built-in concept of staleness, background refetching, or cache invalidation because it doesn't need one.
React Query is a server state manager. It's designed specifically for data that originates from a backend for fetching, caching, deduplicating requests, and keeping that data fresh across your app.
Using Zustand to solve this problem would mean manually writing all the cache logic React Query gives you for free. In fact, the ideal architecture uses both React Query for server data, Zustand for client-only state. That's a topic for another post. For now, our problem is server state sync, so React Query is the right tool.
The Fix: 3 React Query Patterns
I had React Query installed but wasn't using it effectively each screen was still managing its own fetch lifecycle. The fix came down to three focused patterns.
Pattern 1: A Query Keys Factory
A centralised key system is what makes targeted cache invalidation possible. Instead of scattering string literals across your codebase, you define them once:
// lib/query-keys.ts
export const queryKeys = {
items: {
all: ['items'] as const,
list: (userId: string) => ['items', 'list', userId] as const,
detail: (id: string) => ['items', 'detail', id] as const,
},
};
// Define what needs invalidating after each action
export const invalidationGroups = {
itemUpdated: [queryKeys.items.all],
itemCreated: [queryKeys.items.all],
itemDeleted: [queryKeys.items.all],
};
The as const assertions give you full TypeScript inference downstream. Grouping by action means you invalidate precisely what's needed.
Pattern 2: Mutations with Auto-Invalidation
This is where the real magic happens. Mutation hooks that automatically invalidate related queries after every operation:
// hooks/useMarkComplete.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { queryKeys, invalidationGroups } from '../lib/query-keys';
// useSessionStore is a Zustand store holding auth state.
// Replace this with however your app stores the current user —
// Clerk, Firebase Auth, Supabase, or your own auth hook.
import { useSessionStore } from '../store/useSessionStore';
interface Item {
id: string;
completed: boolean;
// ...other fields
}
export function useMarkComplete() {
const queryClient = useQueryClient();
const userId = useSessionStore(state => state.userId);
return useMutation({
mutationFn: (id: string) => api.markComplete(id),
// Optimistic update — instant UI feedback before the API responds
onMutate: async (id) => {
// Cancel any in-flight queries to prevent race conditions
await queryClient.cancelQueries({ queryKey: queryKeys.items.all });
// Snapshot current data so we can roll back if needed
const previous = queryClient.getQueryData(queryKeys.items.list(userId!));
// Optimistically update the cache right now
queryClient.setQueryData(
queryKeys.items.list(userId!),
(old: Item[] | undefined) =>
old?.map(item => item.id === id ? { ...item, completed: true } : item)
);
return { previous };
},
// Rollback on error — restore the snapshot
onError: (_err, _id, context) => {
if (context?.previous) {
queryClient.setQueryData(queryKeys.items.list(userId!), context.previous);
}
// Surface the error to your UI here — toast, alert, inline message, etc.
},
// Use onSettled (not onSuccess) so invalidation fires on both
// success AND error — prevents stale data after a failed mutation
onSettled: () => {
invalidationGroups.itemUpdated.forEach(key => {
queryClient.invalidateQueries({ queryKey: key });
});
},
});
}
Why
onSettledinstead ofonSuccess? If we only invalidate on success, a failed mutation leaves the cache with the optimistically-updated (incorrect) data indefinitely.onSettledruns regardless of outcome, so the cache always resolves to ground truth after any mutation. This is the React Query v5 recommended approach.A common v5 confusion: React Query v5 removed
onSuccess,onError, andonSettledcallbacks fromuseQueryanduseInfiniteQuerybut they remain fully supported inuseMutation. Mutations are imperative by nature, so callbacks are still the right model there. The switch toonSettledhere is a best-practice recommendation, not a required v5 migration.Why
userId!? The non-null assertion is safe here because the query is guarded withenabled: !!userId(see Pattern 3 below). The query will never fire and therefore this mutation will never be callable whenuserIdis null.
Pattern 3: Connect to React Native App State
React Query's refetchOnWindowFocus works automatically in web browsers, but React Native needs an explicit bridge to the OS-level app state:
// app/_layout.tsx
import { QueryClient, QueryClientProvider, focusManager } from '@tanstack/react-query';
import { AppState, AppStateStatus } from 'react-native';
import { useEffect } from 'react';
// Note: In React Query v5, `cacheTime` was renamed to `gcTime`.
// If you're on v4, use `cacheTime` instead of `gcTime`.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 2 * 60 * 1000, // Data is fresh for 2 minutes
gcTime: 5 * 60 * 1000, // Cache persists for 5 minutes after unmount
refetchOnWindowFocus: true,
},
mutations: {
retry: 0, // Don't auto-retry mutations — let the user decide
},
},
});
// Bridges React Native's AppState to React Query's focus manager.
// Without this, refetchOnWindowFocus does nothing in a native context.
function AppStateHandler() {
useEffect(() => {
const subscription = AppState.addEventListener(
'change',
(status: AppStateStatus) => {
focusManager.setFocused(status === 'active');
}
);
return () => subscription.remove();
}, []);
return null;
}
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<AppStateHandler />
{/* rest of your app */}
</QueryClientProvider>
);
}
When the user backgrounds and returns to the app, focusManager triggers a refetch of any stale queries keeping data fresh across app sessions, not just screen navigations.
The New Architecture
Screen A Screen B Screen C
│ │ │
└─────────────────┴─────────────────┘
│
▼
┌─────────────────────────────────────┐
│ React Query Shared Cache │
│ ['items'] queries │
└─────────────────────────────────────┘
│
┌────────────┴────────────┐
│ │
▼ ▼
On Mutation: All screens
invalidateQueries() → auto-refetch
Now when any screen calls markComplete():
- UI updates instantly via the optimistic update
- API call happens in the background
- On settle (success or error), related queries are invalidated
- Every screen subscribed to those queries auto-refreshes
No manual refresh. No stale data. No coordination code between screens.
Keeping It Backward Compatible
One concern with refactoring is breaking existing screens. The cleanest approach is to wrap the new React Query internals behind the same hook interface your screens already use:
// hooks/useItems.ts
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
import { queryKeys } from '../lib/query-keys';
import { useMarkComplete } from './useMarkComplete';
import { useSessionStore } from '../store/useSessionStore';
export function useItems() {
const queryClient = useQueryClient();
const userId = useSessionStore(state => state.userId);
const markCompleteMutation = useMarkComplete();
const query = useQuery({
queryKey: queryKeys.items.list(userId!),
queryFn: () => api.getItems(userId!),
// Only fetches when userId exists — makes the userId! assertion above safe
enabled: !!userId,
});
return {
// Same shape as before — existing screens need zero changes
data: query.data ?? [],
// isPending is the correct v5 flag for "data not loaded yet".
// isLoading was redefined in v5 as isPending && isFetching, which
// returns false when enabled: false — causing a silent empty-state
// bug on first render before userId is available.
loading: query.isPending,
refreshing: query.isRefetching,
error: query.error,
// mutateAsync returns a Promise and throws on error — wrap in try/catch.
// Use mutate instead if you prefer fire-and-forget without error throwing.
markComplete: (id: string) => markCompleteMutation.mutateAsync(id),
refresh: () => queryClient.invalidateQueries({
queryKey: queryKeys.items.all,
}),
};
}
Your screens call useItems() exactly as they did before. The sync behaviour is just... there now.
Quick Start Template
If you're starting fresh:
npm install @tanstack/react-query
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../lib/api';
// 1. Query keys
const keys = {
items: {
all: ['items'] as const,
list: () => ['items', 'list'] as const,
}
};
// 2. Query hook
const useItems = () => useQuery({
queryKey: keys.items.list(),
queryFn: () => api.getItems(),
staleTime: 2 * 60 * 1000,
});
// 3. Mutation with invalidation on settle
const useUpdateItem = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.updateItem(id),
onSettled: () => {
queryClient.invalidateQueries({ queryKey: keys.items.all });
},
});
};
// 4. Use in any screen — they'll all stay in sync
function AnyScreen() {
const { data } = useItems();
const { mutate: update } = useUpdateItem();
// Replace data[0]?.id with however you get the target item's id
// e.g. from route params, a selected state, a FlatList renderItem, etc.
const itemId = data[0]?.id;
return (
<Button onPress={() => update(itemId)} title="Mark Complete" />
);
}
Key Takeaways
Multiple useState hooks for the same server data will always drift. It's not a discipline problem, it's an architectural one. Centralise with React Query.
Design your query keys for invalidation from the start. Flat string keys are fine for small apps, but a factory pattern scales cleanly and eliminates typo bugs.
Use onSettled, not onSuccess, for cache invalidation. It guarantees the cache resolves to ground truth after every mutation, regardless of outcome.
Always guard queries with enabled: !!userId. In React Native, auth state often isn't available on the first render. Without this guard, your query fires with a null user ID and fails silently.
Optimistic updates are table stakes in 2026. Users expect instant feedback. The rollback pattern handles failures gracefully without complex error state.
Backward-compatible migrations are always possible. Wrap new internals behind existing interfaces and refactor incrementally without coordinating a team-wide rewrite.
React Native needs explicit AppState integration. Don't skip the focusManager setup without it, refetchOnWindowFocus is a silent no-op in a native context.
What's Next
This solves server state sync cleanly. But what about client-only state — filters, selections, UI flags — that also needs to be shared across screens?
That's where Zustand comes in, and it pairs beautifully with React Query. Upcoming Post covers how to combine both tools into a complete React Native state architecture, with clear boundaries on what belongs where.
Have you been managing cross-screen state with manual refreshes? Or already using React Query, what patterns have worked for you? Drop a comment below.
Top comments (0)