React Query for React Native: Mastering Data Fetching Beyond the Basics

Alright, fellow React Native adventurers. Let’s talk about something that keeps a lot of us up at night: data fetching. We’ve all been there, right? Juggling `useState`, `useEffect`, maybe some global state managers, trying to keep our app’s data in sync with the backend. It’s often a messy, error-prone dance.

For a long time, the go-to was a combination of `fetch` or Axios within `useEffect` hooks, coupled with some basic loading and error state management. But as apps grow, this approach starts to creak. We start worrying about refetching, stale data, cache invalidation, background updates, and, well, the list goes on. It becomes a significant chunk of our development effort, and frankly, it’s not the most exciting part of building mobile apps.

This is where robust data fetching libraries come in, and lately, React Query for React Native (now officially TanStack Query) has been my absolute go-to. It’s not just another tool; it’s a paradigm shift. It takes a lot of the boilerplate and complexity out of managing server state, allowing us to focus on building great features. If you’re coming from a background of managing these concerns manually, or even using simpler state management tools, strap in, because React Query is going to make your life a whole lot easier.

In this post, I want to dive deep into practical, real-world use cases and common challenges you’ll face when implementing React Query in your React Native projects. We’ll cover what makes it tick, how to wield its powerful features effectively, and importantly, the mistakes I’ve made (so you don’t have to!).

The Core of React Query: What It Is and Why It’s a Game Changer

At its heart, React Query is a data-fetching and state management library. But it’s specifically designed for server state. This is a crucial distinction. Think of data that comes from an API – your user profiles, lists of products, comments, etc. This is “server state,” and it behaves very differently from “client state” (like the visibility of a modal or the text in an input field).

Server state is:

  • Asynchronous
  • Potentially stale
  • Shared across components
  • Difficult to update

React Query addresses these challenges head-on with a suite of powerful features:

  • Caching: It automatically caches your fetched data. This means subsequent requests for the same data can be served instantly from cache, improving perceived performance dramatically.
  • Background Refetching: When you navigate back to a screen or re-focus the app, React Query can automatically refetch your data in the background. This ensures your users are always seeing fresh information without them having to manually pull to refresh.
  • Stale-While-Revalidate: This is a core caching strategy. When data is considered “stale” (after a configurable amount of time), React Query will immediately show the cached data to the user while it fetches fresh data in the background. Once the new data arrives, it updates the UI. This provides a snappy UX without serving outdated information.
  • Deduplication: If multiple components on your screen request the same data simultaneously, React Query ensures that only one fetch request is actually made to your API.
  • Automatic Retries: If a network request fails, React Query can automatically retry it a configurable number of times.

This might sound like a lot, but the beauty is that it handles most of this out of the box. You declare what data you need, and React Query takes care of the rest.

Getting Started: Setting Up and First Fetch

Let’s get our hands dirty. First, install the library:

npm install @tanstack/react-query --save
or
yarn add @tanstack/react-query

Next, you need to wrap your application with the QueryClientProvider. This provides the QueryClient instance to your entire app. It’s typically done at the root of your component tree.

// App.js or your root component
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import YourMainApp from './YourMainApp'; // Your main app component
// Create a client
const queryClient = new QueryClient();
function App() {
return (

{/* The React Query DevTools are helpful during development */}



);
}
export default App;

The ReactQueryDevtools are fantastic for debugging. Seriously, turn them on during development. They’ll show you what queries are active, their status, and how often they’re refetching. Priceless.

Now, let’s make our first fetch. You’ll use the useQuery hook. It takes a query key (a unique identifier for your query) and a query function (which fetches your data).

// YourComponent.js
import React from 'react';
import { View, Text, ActivityIndicator } from 'react-native';
import { useQuery } from '@tanstack/react-query';
// A function that fetches your data
const fetchTodos = async () => {
const response = await fetch('https://jsonplaceholder.typicode.com/todos/1');
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
function TodosComponent() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['todos', 1], // Unique key for this query
queryFn: fetchTodos, // The function that fetches data
});
if (isLoading) {
return (
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}\u003e
<ActivityIndicator size="large" /\u003e
<Text>Loading todos...</Text>
</View>
);
}
if (isError) {
return (
<View>
<Text>An error occurred: {error.message}</Text>
</View>
);
}
// Data is available
return (
<View>
<Text>Todo Title: {data.title}</Text>
<Text>Completed: {data.completed ? 'Yes' : 'No'}</Text>
</View>
);
}
export default TodosComponent;

Notice how `useQuery` returns `isLoading`, `isError`, and `error` states directly. This simplifies your component logic significantly. You don’t need separate `useState` hooks for these.

Beyond the Basics: Key Features for Real-World Apps

Okay, fetching a single item is cool, but real apps are more complex. Let’s explore features that are crucial for production apps.

Handling Loading and Error States Gracefully

We saw the basic `isLoading` and `isError` flags. But often, you want more granular control. For example, when you’re refetching data in the background, you don’t want to show a full-screen loader again. React Query provides:

  • isFetching: True when a fetch is in progress, including background fetches.
  • isInitialLoading: True only during the very first fetch for a query.

This allows you to implement UI patterns like showing a subtle loading indicator (e.g., a spinner in a list item) for background refetches, rather than blocking the entire screen. You can also use these flags to decide whether to show cached data while a background fetch is happening.

For error handling, besides the `error` object, you can configure:

  • retry: Number of times to retry on failure.
  • retryDelay: Function to determine the delay between retries.
  • throwOnError: If `true`, the error will be thrown by the hook and can be caught by your own `try…catch` blocks or higher-order error boundary components. This is great for centralized error handling.
// Example with throwOnError and custom retry
const { data, isLoading, isError, error, isFetching } = useQuery({
queryKey: ['myData'],
queryFn: fetchData,
retry: 3, // Retry up to 3 times on failure
retryDelay: attemptIndex => Math.min(1000 * 2**attemptIndex, 30000), // Exponential backoff, max 30s
throwOnError: true, // Lets you catch errors if needed
});
// In your component:
if (isLoading) return <Spinner />;
if (isError) return <ErrorDisplay message={error.message} />;
// If you use throwOnError, you might wrap this in a try-catch
try {
// ... render data
} catch (e) {
// Handle error globally if not handled by throwOnError or error boundaries
return <ErrorDisplay message={e.message} />;
}

Optimistic Updates: Making Your App Feel Instant

This is where React Query truly shines for perceived performance. Optimistic updates mean you update the UI before the server has confirmed the change. If the server confirms, great. If it fails, you revert the UI.

Imagine a user toggling a “like” button. Instead of showing a loading state for that action, you immediately change the button’s state (e.g., from “Like” to “Liked”) and increment a counter. If the API call succeeds, you’re done. If it fails, you revert the change.

React Query makes this manageable with onMutate, onSuccess, and onError callbacks within the useMutation hook.

// Assuming a mutation for toggling a todo's completion status
const toggleTodoCompletion = async (id, currentStatus) => {
const response = await fetch(`/api/todos/${id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completed: !currentStatus }),
});
if (!response.ok) {
throw new Error('Failed to toggle completion');
}
return response.json();
};
function TodoItem({ todo }) {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (id) => toggleTodoCompletion(id, todo.completed), // Pass ID to function
onMutate: async (id) => {
// Cancel any outgoing refetches for this specific todo query
// This is important to prevent race conditions where a background refetch
// might overwrite your optimistic update before it's confirmed.
await queryClient.cancelQueries({ queryKey: ['todos', id] });
// Snapshot the previous value
const previousTodo = queryClient.getQueryData(['todos', id]);
// Optimistically update to the new value
queryClient.setQueryData(['todos', id], {
...todo,
completed: !todo.completed, // Flip the status
});
// Return a context with the snapshot so we can revert if necessary
return { previousTodo };
},
onError: (err, id, context) => {
// If the mutation fails, revert to the snapshot
if (context?.previousTodo) {
queryClient.setQueryData(['todos', id], context.previousTodo);
}
// Show a toast or alert for the user
console.error('Failed to toggle todo:', err.message);
},
onSettled: (data, error, id) => {
// Always refetch after error or success to get the latest data
// This ensures consistency if the optimistic update was wrong
// or if there were other changes on the server.
queryClient.invalidateQueries({ queryKey: ['todos', id] });
},
});
return (
<View>
<Text>{todo.title} - {todo.completed ? 'Completed' : 'Pending'}</Text>
<Button
title={mutation.isPending ? 'Saving...' : 'Toggle Completion'}
onPress={() => mutation.mutate(todo.id)}
disabled={mutation.isPending}
/>
</View>
);
}

The key here is `queryClient.cancelQueries` and `queryClient.setQueryData`. You’re manually manipulating the cache. This is powerful but requires careful thought to avoid bugs.

Mutation Madness: POST, PUT, DELETE Like a Pro

While `useQuery` is for fetching, `useMutation` is for performing side effects like creating, updating, or deleting data. It provides similar states to `useQuery`: `isPending`, `isError`, `error`, `isSuccess`.

Beyond optimistic updates, `useMutation` is great for:

  • Queuing Mutations: If a user rapidly clicks a button multiple times, you might want to queue these actions or disable the button. `useMutation`’s `isPending` state helps here.
  • Invalidating Queries: After a successful mutation (e.g., creating a new todo), you want to ensure your list of todos is updated. The `onSettled` or `onSuccess` callbacks are perfect for calling `queryClient.invalidateQueries()`. This tells React Query to refetch any queries that depend on that data, keeping your UI in sync automatically.
// Example: Adding a new todo
const addTodo = async (newTodo) => {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newTodo),
});
if (!response.ok) {
throw new Error('Failed to add todo');
}
return response.json();
};
function AddTodoForm() {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: addTodo,
onSuccess: () => {
// Invalidate the 'todos' query so the list refreshes
queryClient.invalidateQueries({ queryKey: ['todos'] });
// Maybe also invalidate a 'todosCount' query if you have one
// queryClient.invalidateQueries({ queryKey: ['todosCount'] });
},
onError: (err) => {
console.error('Error adding todo:', err.message);
},
});
const handleSubmit = (newTodoText) => {
mutation.mutate({ text: newTodoText, completed: false });
};
return (
<View>
<TextInput placeholder="New Todo" onSubmitEditing={(e) => handleSubmit(e.nativeEvent.text)} /\u003e
{mutation.isPending && <ActivityIndicator />}
</View>
);
}

The `queryKey` in `invalidateQueries` is crucial. You can invalidate specific queries by their exact key, or use wildcards (e.g., `[‘todos’, undefined]`) or even just the first part of the key (e.g., `[‘todos’]`) to invalidate all queries that start with that prefix.

Pagination and Infinite Scrolling: Scaling Your Data

Fetching large datasets requires careful management. React Query provides excellent tools for pagination and infinite scrolling.

For standard pagination (e.g., page 1, page 2, page 3), you’d typically use a query key that includes the page number:

// Fetching a specific page
const fetchPaginatedTodos = async ({ pageParam }) => {
const page = pageParam || 1; // Default to page 1 if pageParam is undefined
const response = await fetch(`/api/todos?page=${page}`);
if (!response.ok) {
throw new Error('Network response was not ok');
}
return response.json();
};
function PaginatedTodos() {
const { data, isLoading, isError, error, isFetchingNextPage, hasNextPage, fetchNextPage } = useInfiniteQuery({
queryKey: ['paginatedTodos'],
queryFn: fetchPaginatedTodos,
initialPageParam: 1, // Start with page 1
getNextPageParam: (lastPage, allPages) => {
// Determine the next page number. This depends on your API response.
// If lastPage.nextCursor exists, use it. Otherwise, return undefined to stop.
return lastPage.nextPage || undefined;
},
});
// ... render data, with a button to call fetchNextPage when hasNextPage is true ...
if (isLoading) return <Loader />;
if (isError) return <ErrorDisplay />;
return (
<View>
{data.pages.map((page, i) => (
<View key={i}>
{page.todos.map(todo => (
<Text key={todo.id}>{todo.title}</Text>
))}
</View>
))}
{isFetchingNextPage ? (
<ActivityIndicator />
) : hasNextPage ? (
<Button title="Load More" onPress={() => fetchNextPage()} /\u003e
) : (
<Text>End of list</Text>
)}
</View>
);
}

The `useInfiniteQuery` hook is specifically designed for this. The `getNextPageParam` function is crucial: it tells React Query how to get the parameter for the *next* page. If it returns `undefined`, React Query knows there are no more pages.

For infinite scrolling, you’d typically integrate `fetchNextPage` with a FlatList‘s onEndReached prop.

Caching Strategies and Refetching: The Sweet Spot

This is where React Query truly simplifies things, but also where you need to understand the knobs to turn.

  • staleTime: How long data is considered “fresh” before it’s considered “stale.” If `staleTime` is 0 (the default), data is considered stale immediately after being fetched. If it’s 5 minutes, data is fresh for 5 minutes.
  • gcTime: Garbage Collection Time. How long inactive (unmounted or unused) cached data is kept in memory. Defaults to 5 minutes. If set to 0, data is never garbage collected. This is useful for keeping data around longer if users might navigate back to it.
  • refetchOnWindowFocus: Automatically refetches when the window/app regains focus. Defaults to `true`.
  • refetchOnMount: Automatically refetches when a component mounts, if the data is stale. Defaults to `true`.
  • refetchInterval: Refetches data at a specified interval (e.g., every 30 seconds). Useful for things like live dashboards.

The “sweet spot” is often about configuring `staleTime` and `gcTime` appropriately for your data. For data that changes frequently or is critical to be up-to-date (like a user’s account balance), you might want a low `staleTime` (or even 0) and rely on `refetchOnWindowFocus` or `refetchInterval`.

For data that is more static (like a list of countries), you can set a higher `staleTime` to reduce unnecessary network requests. If a user navigates away and back within that `staleTime`, they’ll get the cached data instantly without a refetch.

You can set these options globally in your QueryClient configuration, or per query/mutation.

// Global configuration
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
gcTime: 1000 * 60 * 60 * 24, // 24 hours
refetchOnWindowFocus: true,
retry: 3,
},
mutations: {
// Options for mutations if needed
},
},
});

Debugging and Performance Tuning

This is where the ReactQueryDevtools become your best friend. They visually show you:

  • Which queries are active.
  • Their status (fetching, paused, inactive, error).
  • How much data they hold.
  • When they were last updated and how often they refetch.
  • The query key and options used.

Beyond the devtools:

  • Logging: Use `console.log` judiciously within your `queryFn` to see when fetches are happening.
  • Network Inspector: The built-in React Native debugger or tools like Flipper’s Network plugin are essential for inspecting actual API requests and responses.
  • Performance Profiling: React’s Profiler can help identify rendering bottlenecks caused by component updates due to data changes.

A common performance issue is an excessive number of refetches. This can happen if:

  • You have many components using the same query key but with slightly different options that React Query doesn’t see as identical.
  • `refetchOnMount` or `refetchOnWindowFocus` are too aggressive for your use case.
  • Incorrect `staleTime` leading to frequent refetches when stale data would suffice.

To tune, start by looking at the devtools. If a query refetches more often than you expect, examine its `staleTime` and the `refetchOn…` options. If you have many components triggering refetches, ensure they are using consistent `queryKey`s.

Common Pitfalls and How to Avoid Them

Even with a powerful library, you can still trip up. Here are some common mistakes I’ve seen and made:

  1. Over-reliance on `invalidateQueries` for everything: While powerful, blindly invalidating broad query keys can lead to more refetches than necessary. Be specific when possible. Instead of `queryClient.invalidateQueries([‘users’])` after updating a single user, use `queryClient.invalidateQueries([‘users’, userId])` if that’s how you’ve structured your keys.
  2. Not handling `isInitialLoading` vs. `isFetching`: This can lead to flashing loading spinners when background refetches occur, which is a poor user experience. Differentiate between the first load and subsequent refetches.
  3. Complex `queryFn`s: Keep your query functions focused on fetching data. If they involve complex logic or multiple API calls, consider breaking them down or using dedicated utility functions.
  4. Not using `queryKey` effectively: A well-structured `queryKey` (often an array like `[‘posts’, postId, ‘comments’]`) is crucial for cache invalidation and deduping. If you pass objects to `useQuery`, make sure they are stable references or you’re using a hashing mechanism. Arrays are generally preferred.
  5. Ignoring `gcTime`: While you might want data to stay around for a while, a very high `gcTime` for infrequently accessed data can lead to memory bloat in your app. Monitor memory usage if you suspect this.
  6. Overlapping `staleTime` and `refetchOnMount`: If `staleTime` is set to a long duration, but `refetchOnMount` is `true`, you might still be refetching data more often than intended. Understand how these options interact.
  7. Mutating array/object state directly in `onMutate`: When using `setQueryData` to update cache optimistically, be mindful of immutability. If you mutate an object or array directly, it can cause unexpected side effects or prevent React from detecting changes. Always spread or use immutable update patterns.

When React Query Might Not Be the Answer

React Query is fantastic for server state. But it’s not a silver bullet for all state management.

  • Complex Client-Side State: If you have deeply nested, highly interactive client-side state that doesn’t involve server communication (e.g., a complex form builder or a drag-and-drop interface), a dedicated client-state manager like Zustand, Jotai, or even React Context might be more appropriate or simpler to integrate.
  • Very Simple Apps: For extremely small, one-off applications with minimal data fetching, the overhead of setting up and learning React Query might outweigh its benefits. A few `useState`/`useEffect` hooks might suffice.
  • Real-time Data (WebSockets): While you *can* integrate WebSocket listeners with React Query (by using `refetchInterval` or manually updating cache), dedicated real-time solutions or libraries might offer a more streamlined experience for highly dynamic, real-time data streams.

The key is to understand the problem domain. React Query excels at making data fetching and caching predictable, performant, and less error-prone. Use it where those problems exist.

Conclusion: Elevating Your React Native Data Game

Managing data in React Native applications can be a significant challenge. By leveraging React Query for React Native (TanStack Query), you gain a powerful, opinionated library that significantly simplifies server state management. Its built-in caching, background refetching, optimistic updates, and mutation handling capabilities can drastically improve your app’s performance and your development efficiency.

Remember to explore the official documentation for comprehensive details. The team behind TanStack Query maintains excellent resources, including guides and API references, that are invaluable for deeper dives. [Check out the TanStack Query documentation here](https://tanstack.com/query/latest).

While there’s a learning curve, especially around configuring cache behavior and mutations, the payoff in terms of a more robust and performant application is immense. Start with the basics, progressively introduce more advanced features like optimistic updates and infinite queries, and always, always use the devtools. Your future self, and your users, will thank you for it.

🎥 Video: State Management in React Native | React Universe On Air #19


📺 Xem trên YouTube

Để lại một bình luận