Apologies for the long question, but I'd be really grateful for some thoughts/help on the best strategy for cache invalidation & refetching queries in Apollo Client 3.
Background
First, some information about the scenario I'm imagining:
- There is an
Account
component (example below) which uses theuseQuery
hook from react-apollo to fetch & display some basic information about an account and a list of transactions for that account - Elsewhere in the app, there is a
CreateTransactionForm
component that uses a mutation to insert a new transaction. This is a separate component which lives at a different location in the component tree and is not necessarily a child of theAccountComponent
) - Crucially, the process of storing a transaction on the server has some non-trivial side effects besides inserting the actual transaction into the database:
- all other transactions which occur after the one being inserted (chronologically) get updated with new running balances
- any related account(s) are updated with a new current balance
A simplistic version of my Account
component might look something like this:
import { gql, useQuery } from '@apollo/client';
import React from 'react';
import { useParams } from 'react-router-dom';
const GET_ACCOUNT_WITH_TRANSACTIONS = gql`
query getAccountWithTransactions($accountId: ID!) {
account(accountId: $accountId) {
_id
name
description
currentBalance
transactions {
_id
date
amount
runningBalance
}
}
}
`;
export const Account: React.FunctionComponent = () => {
const { accountId } = useParams();
const { loading, error, data } = useQuery(GET_ACCOUNT_WITH_TRANSACTIONS, {
variables: { accountId },
});
if (loading) { return <p>Loading...</p>; }
if (error) { return <p>Error</p>; }
return (
<div>
<h1>{data.account.name}</h1>
{data.account.transactions.map(transaction => (
<TransactionRow key={transaction._id} transaction={transaction} />
))}
</div>
);
};
Potential strategies
I'm evaluating the various options for invalidating parts of the Apollo Client cache and refetching the appropriate data after inserting a transaction. From what I've learned so far, there are a few potential strategies:
a) call the refetch
method returned by useQuery
to force the Account
component to reload its data
- this seems reliable and would go back to the server for fresh data, but the
CreateTransactionForm
would need to be (directly or indirectly) coupled to theAccount
component because something needs to trigger that call torefetch
b) pass the query name (getAccountWithTransactions
) into the refetchQueries
option of the mutation
- similar to a, but with potentially even tighter coupling - the
CreateTransactionForm
would need to have knowledge about every other component/query that exists in the app and could be affected by the mutation (and if more are added in the future, it would mean remembering to update theCreateTransactionForm
)
c) manually modify the contents of the cache after performing mutations
- I imagine this would be quite complex/hard to maintain because the
CreateTransactionForm
would need to know exactly what data has changed as a result of the server's actions. As mentioned, this might not be a trivial amount of data and after performing the mutation we'd need to retrieve updated data not only about the transaction that was inserted but also any others that have been updated as a side effect, plus affected accounts, etc. It also might not be very efficient because some of that information may never be viewed in the client again.
My intuition is that none of the options above feel ideal. In particular, I am worried about maintainability as the app grows; if components need to have explicit knowledge about exactly which other components/queries may be affected by changes made to the data graph, then it feels like it would be very easy to miss one and introduce subtle bugs once the app grows to be larger and more complex.
A better way?
I am very interested in the new evict
and gc
methods introduced in Apollo Client 3 and am wondering whether they could provide a neater solution.
What I'm considering is, after calling the mutation I could use these new capabilities to:
- aggressively evict the
transactions
array on all accounts that are included in the transaction - also, evict the
currentBalance
field on any affected accounts
for example:
const { cache } = useApolloClient();
...
// after calling the mutation:
cache.evict(`Account:${accountId}`, 'transactions');
cache.evict(`Account:${accountId}`, 'currentBalance');
cache.gc();
The above provides an easy way to remove stale data from the cache and ensures that the components will go to the network the next time those fields queries are performed. This works well if I navigate away to a different page and back to the Account
page, for example.
My main question (finally!)
This leads onto the main piece of the puzzle that I'm unsure about:
is there any way to detect that some or all of the data referenced in a query has been evicted from the cache?
I'm not sure if this is a feasible thing to expect of the library, but if it's possible I think it could result in simpler code and less coupling between different parts of the app.
My thinking is that this would allow each component to become more "reactive" - the components simply know which data they depend on and whenever that data goes missing from the underlying cached graph it could immediately react by triggering a refetch on its own query. It would be nice for components to be declaratively reacting to changes in the data that they depend on, rather than imperatively communicating to triggering actions on each other if that makes sense.