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
Accountcomponent (example below) which uses theuseQueryhook 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
CreateTransactionFormcomponent 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
CreateTransactionFormwould need to be (directly or indirectly) coupled to theAccountcomponent 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
CreateTransactionFormwould 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
CreateTransactionFormwould 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
transactionsarray on all accounts that are included in the transaction - also, evict the
currentBalancefield 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.