HomeAbout Me

Cache Management

By Daniel Nguyen
Published in React JS
March 31, 2024
8 min read
Cache Management

Background

Application state management is arguably one of the hardest problems in application development. This is evidenced by the myriad of libraries available to accomplish it. In my experience, the issue is made even more challenging by over-engineering, pre-mature abstraction, and lack of proper categorizing of state.

State can be lumped into two buckets:

  1. UI state: Modal is open, item is highlighted, etc.
  2. Server cache: User data, tweets, contacts, etc.

A great deal of complexity comes when people attempt to lump these two distinct types of state together. When this is done, UI state which should not be global is made global because Server cache state is typically global so it naturally causes us to just make everything global. It’s further complicated by the fact that caching is one of the hardest problems in software development in general.

We can drastically simplify our UI state management if we split out the server cache into something separate.

A fantastic solution for managing the server cache on the client is react-query. It is a set of React hooks that allow you to query, cache, and mutate data on your server in a way that’s flexible to support many use cases and optimizations but opinionated enough to provide a huge amount of value. And thanks to the power of hooks, we can build our own hooks on top of those provided to keep our component code really simple.

Here are a few examples of how you can use react-query that are relevant for our exercise:

function App({tweetId}) {
const result = useQuery({
queryKey: ['tweet', {tweetId}],
queryFn: (key, {tweetId}) =>
client(`tweet/${tweetId}`).then(data => data.tweet),
})
// result has several properties, here are a few relevant ones:
// status
// data
// error
// isLoading
const [removeTweet, state] = useMutation(() => tweetClient.remove(tweetId))
// call removeTweet when you want to execute the mutation callback
// state has several properties, here are a few relevant ones:
// status
// data
// error
}

📜 here are the docs:

That should be enough to get you going.

Example

👨‍💼 Our users are anxious to get going on their reading lists. Several already have some books picked out! We’ve got the backend and the UI all ready to go. Now you need to wire up our UI with those APIs and we’ll be good to go.

Here are a few new client endpoints you’ll need to know about:

  • GET: list-items - get the user’s list items
  • POST: list-items with data - create user list items
  • PUT: list-items/${listItemId} with data - update a list item
  • DELETE: list-items/${listItemId} - delete a list item
  • GET: books/${bookId} - get data on a specific book

This stuff touches a lot of files, but I’m confident that you can get this working. Good luck!

NOTE: If it feels like you’re doing a fair amount of copy paste (especially for getting list items) it’s because you are. We’ll clean that up in an extra credit.

One thing to keep in mind is that if you have two resources that are the same used in two different components, you want to make sure that both the queryKey is the same otherwise you’ll have two entries for that resource in the cache. Also, make sure their queryFn do the same thing or you’ll have some pretty odd behavior! Don’t worry if this feels really complicated at first. The extra credit will really simplify things for you!

Make hooks

How are you enjoying all this repetition? No? Yeah, I’m not a big fan either. Here’s where React hooks come in really handy! Let’s make a few custom hooks. Here are a few ideas:

  • useBook(bookId, user)
  • useBookSearch(query, user)
  • useListItem(user, bookId)
  • useListItems(user)
  • useUpdateListItem(user)
  • useRemoveListItem(user)
  • useCreateListItem(user)

This should really help simplify all the components in the app that require some data.

Files:

  • src/utils/books.js
  • src/utils/list-items.js
  • src/components/status-buttons.js
  • src/components/rating.js
  • src/components/book-row.js
  • src/screens/discover.js
  • src/screens/book.js
  • src/components/list-item-list.js,

Wrap the <App /> in a <ReactQueryConfigProvider />

Currently, we’re not doing any error handling for our queries. If there’s an error, we don’t show the user at all.

For example, try to go to /book/not-a-book-id: http://localhost:3000/book/not-a-book-id

You’ll just sit there with a loading book forever while react-query continues to retry forever.

We already have error boundaries set up for this app to handle runtime errors. Let’s reuse those same error boundaries to handle errors in querying for data!

Another thing you might notice is react-query is pretty eager to update its cache which results in lots of requests in our network tab. This is actually great because it means our app’s data won’t have as many stale data issues. However it’s maybe a little more eager than you might want. Luckily, react-query gives us all the knobs we need to turn to tweak how frequently to update the cache and how many times to retry.

In the src/index.js file, create a queryConfig object here and enable useErrorBoundary and disable refetchOnWindowFocus for queries (not for mutations though). You may also consider customizing the retry option as well. See if you can figure out how to make it not retry if the error status is 404 or if the failure count is greater than 2.

📜 Learn more about error boundaries: https://react.dev/reference/react/Component#catching-rendering-errors-with-an-error-boundary

📜 Learn more about query config: https://github.com/TanStack/query/blob/2.x/docs/src/pages/docs/api.md#reactqueryconfigprovider

const queryConfig = {
queries: {
/* your global query config */
},
}
ReactDOM.render(
<ReactQueryConfigProvider config={queryConfig}>
<App />
</ReactQueryConfigProvider>,
document.getElementById('root'),
)

Once you’re finished, try going to http://localhost:3000/book/not-a-book-id again and it should give you an error message and not retry the request anymore.

Files:

  • src/index.js

Handle mutation errors properly

Production deploy

Currently, if there’s an error during a mutation, we don’t show the user anything. Instead, we should show the error message to the user. We’ll need to do a few things to make this work everywhere.

You can test this behavior by using the app DevTools (hover over the bottom of the page) and add a request failure config for PUT requests to /api/list-items/:listItemId, or type “FAIL” in the notes.

Let’s start with showing an error message for the notes and rating. For those, we simply need to access the error state and display it.

The <NotesTextarea /> component in src/screens/book.js will need to destructure the error and isError properties (const [mutate, {error, isError}] = useUpdateListItem(user)) and use those to display the error inline. You can use this UI:

import {ErrorMessage} from 'components/lib'
// ... then in the component next to the label:
{
isError ? (
<ErrorMessage
error={error}
variant="inline"
css={{marginLeft: 6, fontSize: '0.7em'}}
/>
) : null
}

For the Rating component in src/components/rating.js, you’ll do basically the same thing. Put the UI next to the stars.

Next, let’s handle those status buttons (the create/update/delete buttons). For those, you’ll notice that each is a TooltipButton in src/components/status-buttons.js. The TooltipButton is using useAsync and passing the return value of onClick to run. We need the promise onClick returns, to reject so we can show the error.

To make this work, we need to update the useMutation functions in the src/utils/list-items.js to accept options (so I should be able to call useUpdateListItem(user, {throwOnError: true})).

Next, we’ll need to enable throwOnError in src/components/status-buttons.js. Here’s what the throwOnError does:

const [mutate] = useMutation(
() => {
throw new Error('oh no, mutation failed!')
},
{throwOnError: true},
)
const success = () => console.log('success')
const failure = () => console.log('failure')
mutate().then(success, failure)
// {throwOnError: false} (which is the default) would log: "success"
// {throwOnError: true} logs: "failure"

In our TooltipButton component, we’re handling the mutation errors with our own useAsync hook, so we want the error to propagate rather than be handled by react-query. This being the case, the hooks we’re calling in the StatusButtons component should configure throwOnError to true.

You might also see if you can figure out how to make it so we reset the error state if the user clicks the tooltip button when it’s in an error state. (You can call reset from useAsync).

Files:

  • src/utils/list-items.js
  • src/screens/book.js
  • src/components/rating.js
  • src/components/status-buttons.js

Add a loading spinner for the notes

Production deploy

If you made it this far, then you’re a real champ. I’m going to let you figure this one out on your own. Try to add an inline loading spinner to the notes in src/screens/book.js.

Tip: you can get isLoading from the mutation query.

Files:

  • src/screens/book.js

Prefetch the book search query

Production deploy

Right now, open up the app and do this:

  1. Go to the discover page.
  2. Add the first book that comes back to your list (without typing in the search)
  3. Click that book
  4. Click the back button
  5. Notice that the book you added is in the search results for a moment and then disappears.

The reason this happens is because react-query has cached our search for an empty string and when the user returns to this page they’re looking at cached results. However, the server will respond with only books that are not in the user’s reading list already. So while we’re looking at the stale data, react-query validates that stale data, finds that the data was wrong and we get an update.

This isn’t a great user experience. There are various things we can do to side-step this. We could clear the react-query cache (something worth trying if you want to give that a go, be my guest!). But instead, what we’re going to do is when the user leaves the discover page, we’ll trigger a refetch of that query so when they come back we have the search pre-cached and the response is immediate.

To do this, you’ll need a refetchBookSearchQuery function in the books.js util and an effect cleanup that calls this utility in the discover.js component.

📜 You’ll want to use react-query’s queryCache.prefetchQuery and queryCache.removeQueries functions:

Files:

  • src/utils/books.js
  • src/screens/discover.js

Add books to the query cache

Production deploy

Right now, open up the app and do this:

  1. Go to the discover page.
  2. Click any book.
  3. Notice that there’s a loading state while we’re loading the book’s information

One thing you might notice about this is that we actually have all the data we need already from the search results page! There’s no reason to load the book data. The problem is that the discover page is caching book search results and the book page is trying to get books from the cache by a different query key.

You’ll notice this same problem if you add a book to your reading list, then refresh and click on that list item. You should have everything you need already, but the query cache wasn’t populated properly.

There are a few ways we could solve this, but the easiest is to just leave our queries as they are and pre-populate the query cache with the books as we get them. So when the search for books is successful, we can take the array of books we get back and push them into the query cache with the same query key we use to retrieve them out of the cache for the book page.

To do this, we can add an onSuccess handler to our book search query config. We’ll want to do something similar for the list items (because the book data comes back with the list item as well). So when either request is successful, you’ll want to set the book data in the query cache for that book by it’s ID. Try to figure that out.

💰 You may find it helpful to create a setQueryDataForBook function in src/utils/books.js and export that so you can use that function in src/utils/list-items.js.

Keep in mind, the query cache identifies a resource by it’s key. The key for a book is: ['book', {bookId}].

📜 Here are some docs you might find helpful:

Files:

  • src/utils/books.js
  • src/utils/list-items.js

Add optimistic updates and recovery

Production deploy

What percent of mutation requests (requests intended to make a change to data) in your app are successful? 50%? 70%? 90%? 99%? I would argue that the vast majority of requests users make in your apps are successful (if not, then you have other problems to deal with… like annoyed users). With that in mind, wouldn’t it make sense to assume that the request is going to succeed and make the UI appear as if it had? Successful until proven otherwise?

This pattern is called “Optimistic UI” and it’s a great way to make users feel like your app is lightning fast. Unfortunately it often comes with a lot of challenges primarily due to race-conditions. Luckily for us, react-query handles all of that and makes it really easy for us to change the cache directly and then restore it in the event of an error.

Let’s make our list items optimistically update when the user attempts to make changes. You’ll know you have it working when you mark a book as read and the star rating shows up instantly. Or if you add a book to your reading list and the notes textarea shows up instantly.

📜 To make the proper changes to the list item mutations, you’ll need to know about the following things:

This one is definitely a challenge. It’ll take you more than a few minutes to figure it out. I suggest you take your time and try and work it out though. You’ll learn a lot!

A good way to test this one out in the app is the rating. Click one star and move the mouse away and the stars should show your selection immediately.

Files:

  • src/utils/list-items.js

🦉 Elaboration and Feedback

After the instruction, if you want to remember what you’ve just learned, then fill out the elaboration and feedback form:

https://ws.kcd.im/?ws=Build%20React%20Apps&e=06%3A%20Cache%20Management&em=


Tags

#BuildReactApp

Share

Previous Article
Add Styles

Table Of Contents

1
Background
2
Example
3
Make hooks
4
Wrap the <App /> in a <ReactQueryConfigProvider />
5
Handle mutation errors properly
6
Add a loading spinner for the notes
7
Prefetch the book search query
8
Add books to the query cache
9
Add optimistic updates and recovery
10
🦉 Elaboration and Feedback

Related Posts

Authentication
March 29, 2024
2 min
© 2025, All Rights Reserved.
Powered By

Quick Links

About Me

Legal Stuff

Social Media