Setting Up TanStack Query in React Native CLI
Published: October 1, 2025
If you’ve been managing API calls in React Native with useEffect and useState, you know the pain. Loading states everywhere, manual error handling, cache invalidation nightmares. I recently integrated Tansact Query (formerly React Query) into a fintech app and the difference is night and day. Most tutorials cover how to setup TanStack Query on the web , well i take a different approach today to go over how i will setup TanStack Query on mobile - React native specifically.
Am a TypeScript fan boy, but i will keep things simple and use javascript throughout this writing.
Installation
npm install @tanstack/react-query
# or
yarn add @tanstack/react-query That’s it. No peer dependencies, no extra setup packages.
Basic Setup
First, wrap your app with QueryClientProvider. This goes at the root level, similar to how you’d wrap with a theme provider.
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // Data stays fresh for 5 minutes
refetchOnWindowFocus: false, // Important for React Native
refetchOnReconnect: true, // Refetch when internet reconnects
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<SomeProvider>
<Routes />
</QueryClientProvider>
</SomeProvider>
);
} Organizing Your Queries
This is my approach: Don’t put query logic directly in components. Create a clean separation between your API calls and query hooks.
File structure:
/src
/api
/queries
userQueries.js
authQueries.js
/services
userService.js
authService.js Service layer (where axios lives):
// api/services/userService.js
import axiosInstance from '../../config/axiosInstance';
import AsyncStorage from '@react-native-async-storage/async-storage';
export const getAccountBalance = async () => {
const userInfo = await AsyncStorage.getItem('userInfo');
const id = JSON.parse(userInfo)?.user?.id;
const res = await axiosInstance.get(`/v1/account-balance/${id}`);
return res.data.account.balance;
};
export const getUserCards = async () => {
const res = await axiosInstance.get('/v1/user/cards');
return res.data.cards;
}; Query hooks layer (where TanStack Query lives):
// api/queries/userQueries.js
import { useQuery } from '@tanstack/react-query';
import { getAccountBalance, getUserCards } from '../services/userService';
export const useFetchUserAccountBalance = () => {
return useQuery({
queryKey: ['userAccountBalance'],
queryFn: getAccountBalance,
staleTime: 10 * 60 * 1000 // you can define a different stale time for each query
});
};
export const useFetchUserCards = () => {
return useQuery({
queryKey: ['userCards'],
queryFn: getUserCards
});
}; Now your components stay clean:
import { useFetchUserAccountBalance } from '../api/queries/userQueries';
const BalanceDisplay = () => {
const { data: balance, isLoading, isError, refetch } = useFetchUserAccountBalance();
if (isLoading) return <AmountLoading />;
if (isError) return <ErrorView onRetry={refetch} />;
return <Text>{formatAmount(balance)}</Text>;
}; Understanding Mutations
useQuery is for fetching data. useMutation is for changing it (POST, PUT, DELETE).
The key difference: queries run automatically, mutations don’t. You trigger them manually.
// api/queries/authQueries.js
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { loginUser } from '../services/authService';
export const useLogin = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: loginUser,
onSuccess: async (data) => {
await AsyncStorage.setItem('userInfo', JSON.stringify(data));
// Invalidate queries that depend on auth state
queryClient.invalidateQueries(['userAccountBalance']);
},
retry: false
});
}; Using it in a component:
const LoginScreen = ({ navigation }) => {
const loginMutation = useLogin();
const handleLogin = async (email, password) => {
try {
await loginMutation.mutateAsync({ email, password });
navigation.navigate('Home'); // or a toast or anything
} catch (err) {
// Error is available in loginMutation.error
Toast.show({
type: 'error',
text1: err.message
});
}
};
return (
<View>
{loginMutation.isPending && <ActivityIndicator />}
{loginMutation.isError && <Text>{loginMutation.error.message}</Text>}
<Button onPress={() => handleLogin(email, password)} />
</View>
);
}; Real-World Example: Session Validation
Here’s where TanStack Query really shines. Most apps need to validate a user’s session on app launch. The old way involves useState, useEffect, try-catch blocks, and manual loading states.
With TanStack Query:
// api/queries/authQueries.js
export const useValidateToken = () => {
return useQuery({
queryKey: ['validateToken'],
queryFn: async () => {
const storedUserInfo = await AsyncStorage.getItem('userInfo');
const parsedUserInfo = JSON.parse(storedUserInfo);
const token = parsedUserInfo?.token || null;
if (!token) {
throw { type: 'NO_TOKEN', shouldRetry: false };
}
try {
const res = await validateToken();
if (res.valid === true) {
return { valid: true, userInfo: parsedUserInfo };
}
throw { type: 'INVALID_TOKEN', shouldRetry: false };
} catch (err) {
if (err.response?.status === 401) {
throw { type: 'EXPIRED_TOKEN', shouldRetry: false };
}
if (err.response?.status >= 500) {
throw { type: 'SERVER_ERROR', shouldRetry: true };
}
if (!err.response) {
throw { type: 'NETWORK_ERROR', shouldRetry: true };
}
throw { type: 'UNKNOWN_ERROR', shouldRetry: true };
}
},
retry: false,
staleTime: Infinity,
refetchOnMount: false
});
}; In your AuthProvider:
export const AuthProvider = ({ children }) => {
const [userInfo, setUserInfo] = useState({});
const {
data: tokenValidation,
isLoading: isValidating,
isError: hasValidationError,
error: validationErrorDetails,
refetch: retryValidation
} = useValidateToken();
const isLoggedIn = tokenValidation?.valid === true;
const splash = isValidating;
const shouldShowRetry = hasValidationError && validationErrorDetails?.shouldRetry === true;
// Rest of your auth logic
}; Now your navigation logic becomes simple:
const StackNavigator = () => {
const { splash, isLoggedIn, shouldShowRetry, retryValidation } = useContext(AuthContext);
if (splash) {
return <SplashScreen />;
}
if (shouldShowRetry) {
return <RetryScreen onRetry={retryValidation} />;
}
return isLoggedIn ? <AuthenticatedStack /> : <UnauthenticatedStack />;
}; My recommendations
1. Don’t put retry: 2 unless your backend is idempotent
If a POST request fails, you probably don’t want it retrying automatically. That’s how users end up charged twice.
2. Use throw for errors, not return
In your queryFn, throwing an error triggers the error state. Returning an error object means TanStack Query thinks it succeeded.
// Wrong
queryFn: async () => {
if (!token) {
return { error: 'No token' }; // TanStack thinks this succeeded
}
};
// Right
queryFn: async () => {
if (!token) {
throw new Error('No token'); // TanStack knows this failed
}
}; The biggest benefit isn’t the loading states or error handling. It’s that you stop thinking about data fetching as a component concern. Your components render data. TanStack Query manages getting it there.
Before: “I need to fetch this data, handle loading, handle errors, cache it somehow, invalidate it when needed, refetch on mount sometimes but not always…”
After: “I need this data. Here’s the query key.”
That’s the whole mental model. Everything else just works.
Let's connect
Always interested in good conversations about technology, football, or interesting projects.