React Query + React Native: A Love Story at Found

Ryan Donald Liszewski
FOUND ENGINEERING
Published in
9 min readAug 16, 2023
As Taylor Swift said, “It’s a love story…”

Like all good love stories, there’s ups, downs, pain, suffering, and as the famous saying goes, “is it better to have loved and lost, than never to have loved at all.”

However, the spoiler alert about this love story that I will share, is that while there have been ups and downs, there is no lost love here. We’re truly in love with React Query (aka Tanstack Query) and it’s handling of server state in the Found mobile application.

Love at First Sight: Choosing React Query

There’s no question that React Query has become one of the industry’s standard libraries for managing sever state in React Applications. Found’s mobile app, built with React Native, allows for it to use React Query on the Javascript thread where React is rendered.

So, with its declarative nature, hook orientated architecture, and powerful cache mechanism, it’s the whole package, and it was truly love at first sight for me (and Found) 😍.

React Query put on the charm right at the beginning, with its “manifesto” of why it exists, explaining:

Out of the box, React applications do not come with an opinionated way of fetching or updating data from your components so developers end up building their own ways of fetching data.

React Query allows you to defeat and overcome the tricky challenges and hurdles of server state and control your app data before it starts to control you.

https://tanstack.com/query/v3/docs/react/overview

My knees started to weaken. No more redux? No custom API client? Clear separation of concerns between local and server state? I’m in love.

(In all fairness, so was the team at Found before I joined. It literally was one of the selling points for joining the mobile team at Found. You use React Native and React Query? Where do I sign up?)

The Honeymoon Phase: State Management Stack with React Query

Concerns were clearly separated between local, and server state, which helped scale the application rapidly. React Query lived up to its manifesto of handling server state, and we couldn’t have been happier.

Diagram 2: What true love looks like

Zustand and Context handled the concern of the application’s local state, with the goal of keeping stores localized following the the single responsible principle (SRP).

When it’s all said and done, Zustand and Context stores made up only about 20% of the application’s total state (local state + server state) relative to the amount of React Query’s queries. We didn’t need to over engineer our stack with Redux or another complex state management library. Our stack was scaling well with the business and user needs.

But with all good rom coms, there’s no good without bad.

Our Problems: Nobody is Perfect

Every relationship has its problems, and ours with React Query was no exception. Although things started out great, as our feature set grew, data got more complex, and problems started to arise:

  1. Caching. Our data wasn’t being updated when product and our users expected it to, leading to a poor UX and a large backlog of caching bugs.
  2. Large amounts of local state, query hooks, and cache queries stuffed into one screen made them a headache to work with. This became unmanageable.
  3. Updating the cache directly wasn’t fun. Why does this have to be so hard?

Long story short, we followed what most rom com storylines do. We got upset, and then subsequently proceeded to flash through a self growth montage (which was me being handed the task of understanding why things weren’t working with React Query, and what should we do about it).

1. It’s Not You, It’s Me: Cache rules everything around me

What won us over with React Query was its powerful caching mechanisms such as low boilerplate, configurable and automatic background refetching to sync the server’s data to the client. But with the influx of caching related bugs, and user complaints, there clearly was a problem going on with our application and React Query’s cache.

While many, many fingers where pointed, glasses were broken, this problem boiled down to, “It’s not you React Query, it’s me (us).”

Properly syncing the cache with Server state

To sync the server with the application’s server state required a simple paradigm shift as follows:

  1. Most of the time, you’re never going to be able to rely on cacheTime and staleTime alone. Always assume you’re going to need to either update the cache directly, or invalidate those queries at some point in your application.
  2. If the server responds with the correct data structure, update the cache directly.
  3. If the server does not respond with the correct data structure, or the cache isn’t easily updated, simply invalidate the query.

Invalidating the cache

Invalidating the cache manually, called a “smart refetch” by the React Query’s maintainer, solved a lot of the problems of syncing the client and server state when we couldn’t update the cache directly.

// Updates the users task log for a specific focus, i.e Getting More Sleep
const {
mutate: taskLogMutate,
isSuccess: isTaskLogMutationSuccess,
isLoading: isTaskLogMutationLoading,
data: taskLogResponse,
} = useTaskLogMutation({
onSuccess: ({ taskLog }) => {
invalidateQueries([
queryStore.focuses.fetch._def,
queryStore.focuses.fetchDetails._def,
queryStore.logs.fetch._def,
]);
},
});

In this example, the user mutates a task log for a specific focus of theirs, and on success of that mutation, the queries that fetch their task logs, and focuses/focuses details to reflect the new task log are all invalidated.

This guarantees a refetch to update the client’s server state in the background, either immediately if the query has an active observer or when a screen is shown to the user that houses the respective query.

React Navigation (Mobile) Lessons

Navigation in a mobile app adds more complexity than a general browser URL routing navigation, by relying on sets of nested navigation patterns (stacks, tabs, modals, drawers).

This was a definetly a learning curve for the Found team when understanding the lifecycle of a query, and when it will refetch based on the it’s cache configuration values, staleTime and cacheTime. I won’t go into detail on those values here, but if you’re curious check out the React Query’s blog post about them.

The two biggest lessons that we learned:

  1. If a screen with a query that has staleTime: 0, cacheTime: 0has a screen pushed on top of the navigation stack, and want the previous query to refetch when popping the current screen, you need to manually invalidate the query in order to refetch.
  2. Continuing from #1, Tab navigators have their root screen always mounted, and therefore if you want a background refetch to occur, any respective queries need a manual invalidation.

tldr; if a screen is mounted, and you navigate to it, it won’t refetch in the background automatically, regardless of what you set staleTime and cacheTime. Those screen’s queries should be manually invalidated with queryClient.invalidateQueries() whenever you want the data to be refetched in the background.

Query Store

We had over 75 queries and no query key store, it was absolute chaos. Adopting a query store was a big and easy win for us when having to query, invalidate and update the cache. We decided to go with Luke Morales query key factory but you can build your own too.

import type { inferQueryKeys } from '@lukemorales/query-key-factory';

import { createQueryKeys } from '@lukemorales/query-key-factory';

export const focusesQueryStore = createQueryKeys('focuses', {
fetch: (category: string) => ['fetch', category],
fetchDetails: (focusId?: string) => ['details', focusId],
});

export type FocusesQueryStoreType = inferQueryKeys<typeof focusesQueryStore>; queryClient.invalidateQeries({ queryKey: queryStore.focuses.fetchDetails(focusId).queryKey,});

The out of the box typing won us over, and we plan on experimenting with adding the request functions in the store to further help centralizing our server data typing in React Query.

2. Talking in Circles: Separating concerns further with Repository hooks

There was no doubt our screen components became super bloated and long with multiple custom query hooks, Zustand store hooks, context, and all the logic to merge local and client state.

Reusing the repository pattern (at a high level) from Domain-Design Driven, we separated concerns of specific domains throughout the application up one level into it’s own custom hook.

This hook responsibility entails, but is not limited too, merging local state and server state, and merging multiple queries into one. The adoption of the pattern isn’t meant to follow the DDD repository one to one, but rather embody the concept of separating the UI as the client to querying and updating the applications state layer.

A great use case of this that React Query doesn’t support out of the box would be a dependent mutation.

export const useTaskLogMutationRepository = ({
image,
taskId,
isEditing,
}: TaskLogRepositoryParams) => {
const { invalidateQueries } = useInvalidateQueries();

// The mutation dependent on useTaskLogMutation
const {
isSuccess: isTaskImageMutationSuccess,
isLoading: isTaskImageMutationLoading,
mutate: taskImageMutate,
} = useTaskImageMutation({
onSuccess: () => {
invalidateQueries(queryStore.challenges.detail._def);
},
});

const {
mutate: taskLogMutate,
isSuccess: isTaskLogMutationSuccess,
isLoading: isTaskLogMutationLoading,
data: taskLogResponse,
} = useTaskLogMutation({
onSuccess: ({ taskLog }) => {
invalidateQueries([
queryStore.focuses.fetch._def,
queryStore.focuses.fetchDetails._def,
queryStore.logs.fetch._def,
]);

// If there's an image, mutate the image using taskLog.id
if (image) {
taskImageMutate({ taskLogId: taskLog?.id, image });
} else {
invalidateQueries(queryStore.challenges.detail._def);
}
},
});

// Merge async state
const isLoading = isTaskImageMutationLoading || isTaskLogMutationLoading;
const isSuccess =
isTaskLogMutationSuccess && (isTaskImageMutationSuccess || !image);

const mutate = useCallback(
(data: ITaskLogMutationParams) => {
taskLogMutate(data);
},
[taskLogMutate],
);

return useMemo(
() => ({ mutate, isSuccess, isLoading, taskLogResponse }),
[mutate, isSuccess, isLoading, taskLogResponse],
);
};

The repository’s responsibility consists of handling the dependent mutations when completing a task, handling the subsequent mutation to upload an image when applicable and merging the async state.

Merging of the async state ensures the UI shows the correct loading feedback for wether there’s a dependent task log image mutation loading, or just simply a task log mutation. Lifting this merging of async logic keeps async state concerns centralized in the repository, making it reusable across the app.

3. No more fights: Immer too the rescue

Server data, due to its nested nature, especially with pagination, makes it hard to update nested object values. This was a constant fight in our relationship when updating cache data using setQueryData , which must be performed in an immutable fashion.

queryClient.setQueryData<InfiniteData<ITagsInfiniteQueryResponse>>(
queryStore.tags.fetch.queryKey,
tags => {
if (tags?.pages) {
return {
...tags,
pages: tags.pages.map(page => ({
...page,
bookmarks: page.bookmarks.map(bookmark =>
bookmark.bookmarkable.text === favoriteTag.bookmarkable.text
? favoriteTag
: bookmark,
),
})),
};
}
return tags;
},
);

In comes Immer to the rescue. While not a mutually exclusive problem to React Query (RTK uses immer automatically under the hood), it saved us a from a lot of public screaming matches.



queryClient.setQueryData<InfiniteData<ITagsInfiniteQueryResponse>>(
queryStore.tags.fetch.queryKey,
tags => {
// ✅ With Immer produce func
const newData = produce(tags, draft => {
const tag = draft?.pages
.flatMap(page => page.bookmarks)
.find(
bookmark =>
bookmark.bookmarkable.text === favoriteTag.bookmarkable.text,
);
if (tag) {
tag.isBookmarked = favoriteTag.isBookmarked;
}
});
return newData;
},
);

Instead of having to recreate the entire nested structure, Immer supplies you with the produce function to update nested values in an object in a performant, readable manner without having to recreate the nested oject ourselves.

The Kiss and Make Up

This won’t resonate with most couples out there, but what worked for us here solving our relationship woes was understanding our problems, talking them through, and actually (this one especially) implementing them.

By adding an extra layer of abstraction of our application state with Repositories, vigilantly invalidating and updating the cache, and making updates to the cache easily, we improved our codebase, tech stack, and most importantly, the Found mobile app’s UX.

Riding Off Into the Sunset

At Found, one of our guiding engineering principles is Iteration over Perfection, and that couldn’t have embodied our relationship with React Query more. While we’ve solved some initial problems with React Query, there are still areas to improve.

What happens if the refetch fails? Is a simple error message a good enough experience? Failure here can have dramatic consequences when dealing with user’s health.

With that said as we iterate on the mobile app, our goal is to make its server state reliable and responsive with our current love, React Query.

(Disclaimer: We actually have real human partners, but probably spend more time with React Query)

--

--