Fetching data from an API is straightforward. Keeping it consistent across screens, handling failed requests, and avoiding unnecessary network calls is where things get more challenging.
Consider renaming a chat. Should the UI wait for the backend to respond before displaying the new title? What happens if the request fails? How do we keep the cached data synchronized with the server?
While building IdeaPilot, an AI-powered project ideation app built with React Native and Expo, I used TanStack Query to handle these problems. This article explores how I implemented query caching, infinite pagination, optimistic updates, and cache invalidation.
1. Why TanStack Query?
Not all application state is the same.
- Server state comes from an external source, such as chat history fetched from an API.
- Client state belongs to the application interface, such as whether a drawer is open.
Server state requires additional handling because it can become stale, requests can fail, and multiple components may need the same data.
TanStack Query provides a structured way to fetch, cache, update, and invalidate server data without manually managing every request.
2. Fetching and caching chat data
In IdeaPilot, I created reusable hooks for fetching chat history and messages.
Fetching chat history
export function useChatHistory(enabled: boolean) {
return useQuery<Chat[]>({
queryKey: ["chats"],
queryFn: async () => {
const { data } = await apiClient.get("/chats");
return data;
},
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 30,
enabled,
});
}
Three options are particularly important:
-
queryKeyidentifies the cached data. Components using the same key can share the query's cached result. -
staleTimedetermines how long data is considered fresh. Here, it is five minutes. -
gcTimedetermines how long inactive query data is retained before garbage collection. Here, it is 30 minutes.
Freshness and cache retention are different. Data can become stale while still remaining in the cache.
Paginating chat messages
Long conversations should not require fetching every message at once. IdeaPilot uses useInfiniteQuery to retrieve messages in pages.
export function useChatMessages(chatId: string) {
return useInfiniteQuery<MessagePage>({
queryKey: ["messages", chatId],
queryFn: async ({ pageParam = null }) => {
const { data } = await apiClient.get(
`/chats/${chatId}/messages`,
{ params: { cursor: pageParam } }
);
return data;
},
initialPageParam: null,
getNextPageParam: (lastPage) =>
lastPage.nextCursor,
staleTime: 1000 * 60 * 5,
gcTime: 1000 * 60 * 15,
retry: 3,
enabled: chatId !== "new",
});
}
The chat ID is part of the query key, so each conversation has its own cache entry. The backend returns a nextCursor, which TanStack Query uses to fetch the next page.
The UI combines the pages for rendering:
const messages = useMemo(
() =>
chatMessages?.pages.flatMap(
(page) => page.messages
) || [],
[chatMessages?.pages]
);
This keeps pagination logic in the data-fetching layer while allowing the UI to work with a single message list.
3. Optimistic updates
An optimistic update changes the UI before the server confirms that an operation succeeded.
For example, when a user renames a chat, waiting for the API response before updating the title can make the interface feel slow. An optimistic update displays the new title immediately and restores the previous value if the request fails.
In IdeaPilot, the rename mutation follows four steps:
- Cancel relevant queries.
- Save the previous cache state.
- Update the cache immediately.
- Roll back on failure and invalidate the query when the mutation settles.
Step 1: Send the API request
mutationFn: async ({
chatId,
title,
}: {
chatId: string;
title: string;
}) => {
const { data } = await apiClient.patch(
`/chats/${chatId}/rename`,
{ title }
);
return data;
}
This performs the actual server operation. The optimistic behavior is handled separately through mutation callbacks.
Step 2: Cancel queries and save a snapshot
onMutate: async ({ chatId, title }) => {
await queryClient.cancelQueries({
queryKey: ["chats"],
});
const previousChats =
queryClient.getQueryData<Chat[]>(["chats"]);
// Apply the optimistic update.
}
Cancelling in-flight queries helps prevent an outdated refetch from overwriting the optimistic change.
The snapshot gives us a copy of the previous cache state that can be restored if the request fails.
Step 3: Update the cache
queryClient.setQueryData<Chat[]>(
["chats"],
(old) => {
if (!old) return [];
return old.map((chat) =>
chat.id === chatId
? { ...chat, title }
: chat
);
}
);
The matching chat is updated immutably, and components observing the query can display the new title immediately.
The important distinction is that this changes the client-side cache, not the database. The server request must still succeed for the change to be persisted.
Step 4: Roll back on failure
onError: (_error, _vars, context) => {
if (context?.previousChats) {
queryClient.setQueryData(
["chats"],
context.previousChats
);
}
}
If the request fails, the previous chat list is restored. This prevents the UI from showing an unsaved change as though it were successful.
Snapshot-based rollback is simple and effective for straightforward interactions, but overlapping mutations can complicate it. Restoring an older snapshot may accidentally undo a more recent change.
4. Cache invalidation
After the mutation settles, IdeaPilot invalidates the chat-history query:
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["chats"],
});
}
Invalidation marks the matching cached data as stale. Active queries are normally refetched, allowing the cache to reconcile with the server.
This matters even after a successful optimistic update because the server may return normalized data or apply rules the client does not know about.
Optimistic updates improve responsiveness; invalidation helps restore consistency.
IdeaPilot applies the same pattern to pinning and deleting chats. When a chat is deleted successfully, its associated message query is also removed from the cache:
onSuccess: (_data, chatId) => {
queryClient.removeQueries({
queryKey: ["messages", chatId],
});
}
This prevents the deleted conversation's messages from remaining in the query cache unnecessarily.
5. Optimistic updates for AI chat messages
Optimistic updates are also useful outside the useMutation lifecycle.
When a user sends a prompt in IdeaPilot, the application immediately inserts the user's message and an empty assistant placeholder into the message cache:
queryClient.setQueryData(
["messages", currentChatId],
(oldData) => {
// Preserve existing pages and parameters.
// Insert the user message and assistant placeholder.
// Return the updated infinite-query data.
}
);
After the API request completes, the placeholder is replaced with the assistant's response. If the request fails, an error message is displayed instead.
This is a manual cache update that makes the chat interface feel responsive while the AI request is in progress.
One technical distinction: although the endpoint is named /stream, the current implementation waits for the completed response and then updates the placeholder. It does not render tokens incrementally as they arrive.
6. What works well and what deserves attention
This approach provides several benefits:
- Shared cache: Multiple components can use the same server data.
- Responsive interactions: Optimistic updates avoid waiting for the server before updating the UI.
- Failure recovery: Snapshots allow the application to roll back unsuccessful mutations.
- Controlled data fetching: Pagination avoids loading an entire conversation at once.
There are also important considerations when applying this pattern in larger applications.
Cache isolation: IdeaPilot currently uses ["chats"] for chat history and ["messages", chatId] for messages. Including the authenticated user's ID in relevant query keys and clearing or resetting cached data on account changes can help prevent stale data from appearing across sessions.
Concurrent mutations: Whole-cache rollback can conflict with overlapping operations. Applications with more concurrent updates may need narrower rollback logic or mutation serialization.
Testing: Both success and failure paths matter. Useful cases include failed renames, failed deletions, overlapping refetches, and account switching.
Retry behavior: Retrying requests that create messages requires care. Without appropriate idempotency handling, a retry could duplicate work.
These considerations help distinguish a responsive implementation from one that also handles more complex failure scenarios reliably.
Conclusion
Building IdeaPilot helped me understand that server-state management involves much more than fetching data.
TanStack Query provides a consistent approach to caching chat history, paginating messages, applying optimistic updates, recovering from failures, and reconciling cached data with the server.
The key takeaway is simple: use optimistic updates for responsiveness, rollback for failure recovery, and invalidation for reconciliation. Together, these techniques make API-driven React Native interfaces easier to manage without requiring every component to implement its own data-fetching logic.
Explore the implementation in the IdeaPilot GitHub repository.
This article describes the current implementation and discusses potential areas for refinement separately.
Top comments (0)