Put a Leash On React Query By Controlling The Cache

Put a Leash On React Query By Controlling The Cache

·

5 min read

I am loving React Query (RQ).

I am no master in building caches but I feel in complete control of RQ... and how many network requests my app is making.

Here's what I was working on the other day.

Updating The Cache

Like many solutions, RQ sets up a cache for you based on the network requests you make using it.

BUT...

RQ is aggressive so it will invalidate a request and re-request the minute you un-focus the window.

And in a real app every request costs money... every read or write to your database costs money.

But you can put a leash on RQ and control when it invalidates (I'll write about this later)... and you can skip requests completely if you tap into the RQ lifecycle.

Skipping Requests

RQ has optimistic updating.

I have not played with this yet. After all... you can optimistically update, but if the "real" request fails... the data is not actually saved to your database.

Your UI says... "It's all good!"

Your database has zero idea of what's happening.

I'll write about this when I venture down that road.

Soooo....

Let's tap into a little RQ lifecycle.

onSuccess

You typically only need to do this when you're using useMutation... because that's when you're actually changing data...

(And that's how you get access to the "old data" you'll see below.)

Data that probably needs to be reflected in the UI after the change... right?

Let's look at some code.

return useMutation(
    async (journalEntryData: SaveJournalEntryData) => {
      const body = {
        ...journalEntryData,
      }

      const res = await fetch(endpoint, {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
        },
        body: JSON.stringify(body),
      })

      if (!res.ok) {
        throw new Error(
          "Could not save your journal entry data. Please refresh the page... reenter your data and try again."
        )
      }

      return res.json()
    },
    {
      onError: () => {},
      onSuccess: (data, variables) => {
        const { programId } = variables
        queryClient.setQueryData(
          ["getJournalEntries", "/api/data/get-journal-entries", programId],
          (oldData: JournalEntryData | undefined): JournalEntryData => {
            const newData = data.newJournalEntry as JournalEntry
            return {
              message: "success",
              data: oldData ? [...oldData.data, newData] : [newData],
            }
          }
        )
      },
    }
  )

A quick walk through...

I'm building a fitness app and part of it are journal entries... more or less personal tweets to record "stuff" that affects your workout day.

In this mutation I am adding a journal entry to my database.

(I'm using FaunaDB . It's a-maze-ing and I'll write about Fauna too in future posts.)

The First Part:

I'm using TS so those custom types are just structuring my data.

I'm making a POST request to my endpoint... a serverless function... that will add the journal entry to the database.

Things get interesting after return res.json()...

Notice the onSuccess block.

The Second Part:

Okay... don't yell at me. I can't remember the third argument the function takes... but it doesn't matter.

First... data

This is the data your endpoint returns... if it returns anything.

So when you write your backend code... it's good to return the data you actually saved that way you can use it.

Second... variables

This includes all the variables you initially passed into the useMutation function.

This is where you can "optimistically update" because you have the what the data will/should be...

So you could update the data and change the UI immediately... but you better make sure you handle if the actual request to your endpoint fails. Then you're out of sync.

I use the variables to grab the programId because I'll need that when updating the cached data in RQ.


Here's the strange part

RQ uses keys to identify the requests in the cache. Makes sense.

But RQ in their docs say if you pass arguments to the initial useQuery function... this is where the cache is set...

Oh man... I can see how right here this is getting confusing.

Stay with me...

I have a GET request to grab all my journal entries. This uses the useQuery hook/function and this creates the entry in the cache as I understand it.

After saving a new journal entry... I want to update the cache.

So I have to grab that part of the cache by its key.

And because you pass arguments... the key is not just the string... it includes the arguments you passed in too.

See this...

queryClient.setQueryData(
          ["getJournalEntries", "/api/data/get-journal-entries", programId],

That array is the key for the cached data.

I'm not sure why I can't just use the string. Let me know if you... know!


This is the key (no pun intended) part...

Because I'm adding new data to an array of data... You can't just overwrite the cache with the new journal entry.

That would delete all previous entries in the cache. Then your UI would be all jacked up... though your database would be in-sync.

So RQ gives you a function and access to the old data... i.e. the data in the cache.

(oldData: JournalEntryData | undefined): JournalEntryData => {
            const newData = data.newJournalEntry as JournalEntry
            return {
              message: "success",
              data: oldData ? [...oldData.data, newData] : [newData],
            }
          }

Keep in mind there might not be old data there... if it's the first journal entry.

So I grab the oldData.

I return the exact API response because that's how my app consumes this data.

If you didn't return the exact response... your app would error out because it's looking for a property that doesn't actually exist anymore.

data: oldData ? [...oldData.data, newData] : [newData]

Here I'm just checking for oldData and if there is oldData I'm spreading it out in the array... and tacking on the new journal entry I just saved to the database.

If there is no oldData I just create a new array with only the new journal entry in it.

Remember... this is happening in the cache. The "real" data has already been saved to the database at this point.

Whoa!

All this crap for one thing...

Now my cache is updated and I don't need to make a request to grab the most up to date data.

I have it already.

This means when I use the useQuery hook to get the journal entries... I can stick a longer staleTime on the query... and avoid a re-fetch.

And because I'm doing this in the onSuccess lifecycle... I know for a fact that the new journal entry is saved in my database.

This Tip of the Iceberg

RQ is pretty sweet. You can do so much.

But realizing that you can use a function and tap into the "old data" and update without making a new request.

That's pretty sweet.

After all... requests costs money.