From Apollo to Urql — Part 2
Our notes at Sesame from migrating a highly active codebase from Apollo-client to Urql.
In the first part we explained our motivation and the gradual migration approach we took while switching from Apollo to Urql.
In this part we will share our experience with:
- Missing pieces (Apollo features that are missing from Urql)
- Testing & Mocking
- Impact
- Conclusion
Missing pieces
fetchMore
Apollo provides a built-in pagination API which is composed of 2 parts:
The fetchMore function, which is returned by the useQuery hook and expects the new offset:
fetchMore({
variables: {
offset: pageSize * pageIndex,
},
});
As well as a merge function so that Apollo knows to concatenate the pages and update the cache.
Merging can be implemented by defining it as part of the cache config or by utilising the updateQuery function:
function mergeData(prev, { fetchMoreResult }) => {
return [...prev, ...fetchMoreResult];
})fetchMore({
variables: {
offset: pageSize * pageIndex,
},
updateQuery: mergeData,
});
Urql does provide a pagination implementation package for the normalized cache , however there seems to be no intention in providing such API for the default, document based cache.
Implementing an alternative pagination logic while keeping the same API proved to be not too much of a hassle (yet not trivial either).
Let’s start with the reducer:
The reason we’re using a map from a page index to its results is to keep the updateResults
action idempotent.
Meaning, no matter how many times you call it for a given page — the results will stay the same.
The above reducer can be used to keep an internal pagination state for a custom usePaginatedQuery hook:
Now we’re able to keep the same fetchMore API as Apollo’s:
const { data, loading, error, fetchMore } = usePaginatedQuery(FEED_QUERY, {limit: 10, offset: 0});
No special cache definition / update is required as each document is stored independently, and the above logic just glues them together.
With this implementation, fetchMore does not require any argument but can simply be called on an infinite scroll or a next page click.
Let’s have a quick run-down on what happens once fetchMore is called:
// user scrolls / clicks on next page button
fetchMore();--->// usePaginatedQuery -> fetchMore increments the page
dispatch({ type: 'incrementPage' });--->// query is run with the new offset
const { data: currentPageData } = useCustomQuery(query, {
variables: {
offset: newPageIndex * limit
},
});--->// currentPageData is returned - merge results effect is triggered
useEffect(mergeResults, [currentPageData]);--->// effect dispatches an update results action with the new page
dispatch({ type: 'updateResults', payload: currentPageData });--->// current page is added to full results
fullResults: [...results, ...currentPageData]---> // returned data is updated
data: fullResults
Polling
Apollo provides an out-of-the-box, handy way to execute a query periodically at a specified interval (polling).
Urql did support polling in the past but it has since then been removed, as documented in Urql comparison to other clients.
The pull request that removed polling provides a handy example on how to achieve it with Urql now:
const [result, executeQuery] = useQuery(...);useEffect(() => {
if (!result.fetching) {
const id = setTimeout(
() => executeQuery({ requestPolicy: 'network-only' }),
5000
);
return () => clearTimeout(id);
}
}, [result.fetching, executeQuery]);
In order to make it fully backward-compatible with Apollo’s interface, we built a custom hook to allow manual start and stop of the polling and expose these function in our useQuery
hook, as follows:
While it was bad timing (no pun intended) that the feature got removed around the same time as our migration effort and that it required some extra work to make it backward-compatible with Apollo, it is to be expected when aiming for a lightweight library.
The more straightforward and less nerve-wrecking test capabilities of Urql is worth writing some glue code for a few unsupported use-cases.
Testing & Mocking
As mentioned before, automated tests was one of the main reasons we decided to move forward with this migration, as mocking is much more straight-forward, controlled and easy to implement With Urql than it is with Apollo.
Let’s dive a bit deeper into the differences.
Mocking with Apollo
Straight from the docs:
import { GET_DOG_QUERY, Dog } from './dog';const mocks = [
{
request: {
query: GET_DOG_QUERY,
variables: {
name: 'Buck',
},
},
result: {
data: {
dog: { id: '1', name: 'Buck', breed: 'bulldog' },
},
},
},
];it('renders without error', () => {
const result = render(
<MockedProvider mocks={mocks} addTypename={false}>
<Dog name="Buck" />
</MockedProvider>,
);
...
});
So far so good, problem starts when the mock and the query are not 100% aligned.
Missing fields
If there’s even a single mismatch between the queried fields and the mocks — Apollo would simply return an empty response. No errors. No indication whatsoever.
So in the above example — if GET_DOG_QUERY
query were to
- Not ask for the
breed
field or - Ask for an extra
age
field - not present in the mocked response
MockedProvider
would just return an empty response ("data": undefined
) and good luck figuring it out with a much larger query.
Queries that run more than once
In the above example — if GET_DOG_QUERY
was called more than once - test would fail with a
ERROR: No more mocked responses for...
Since the mocks array should contain a new instance for every time a query is called.
Mismatching variables
Same opaque No more mocked responses for
error would appear if there was a variables mismatch.
Again, for queries with many sort / filter variables, pinpointing the source is quite tedious.
In real life — All of the above simply means lower test coverage, as mocking becomes a time-consuming, exhausting and sometimes impossible task (imagine debugging a fragmented query which fetches an entire page, and your mock is missing a single field out of a hundred).
Mocking with Urql
Urql has a much more straight forward approach.
No black-boxes, no vague error messages (or lack of ones).
Just a good old, fully controlled and assertable jest.fn
mock
Mocking a single query
So the equivalent of the above Apollo example would be:
Since we control the mocked implementation, we can explicitly assert the arguments are correct.
Or we could follow Apollo’s approach and make the assertions part of the mock:
Mocking multiple queries
The above example is perfect when your component / hook only uses a single query.
But what happens when it uses more than one?
Again, since we control the implementation — we could just go with
const executeQueryImplementation = ({ query, variables }) => {
const resolveDogResponse = () => {
switch (variables.slug) {
case 'Buck':
return fromValue(buckResult);
case 'Snuffles':
return fromValue(snufflesResult);
}
}; switch (query) {
case GET_DOG_QUERY:
return resolveDogResponse();
case GET_CAT_QUERY:
return fromValue(catResult);
default:
return fromValue({});
}
};
Note that in this case — query / variables assertions are implicit like with Apollo.
The important difference is — you could very easily debug in case the response is not as expected.
Gradually migrating the tests
Since the migration was gradual, we had to test every component with both Apollo and Urql while also taking post migration cleanup into account.
Having separate Apollo / Urql test suites (despite possible code duplication) for each container component seemed to be the most maintainable and cleanable approach.
Again we’d use the URQL_ENABLED
flag to determine which test suites should run.
We’d then run both versions separately to have a clear view of which library was having regressions
- name: 'Test - Apollo'
run: |
yarn test
- name: 'Test - Urql'
run: |
URQL_ENABLED=true yarn test
Migrating the tests was the simplest, yet most time-consuming part of the migration.
Once we had full coverage for both Urql and Apollo — we finally set the URQL_ENABLED
flag to true on production.
Impact
As mentioned in the intro, our main objectives for this migration were:
- Reducing main bundle-size
- Easier mocking and testing
- First-class NextJS integration
And indeed in the results were as we expected and more.
Bundle-size
The size of our main bundle, which is shared between all pages, has been reduced from 300.87KB to 268.32KB, over 30kb of gzipped JS saved.
CI test-running time
We did not expect this outcome, but the test running time has been reduced by more than 50%(!), from about ~2 minutes with Apollo to less than 1 minute with Urql.
Even though most of our tests were still simulating a loading state:
import { delay, fromValue, pipe } from 'wonka';export const delayed = data => pipe(fromValue(data), delay(0));
Turns out Urql’s streamed approach is much faster.
Test development time
No numbers here but we can say for sure that writing tests with Urql proved to be trivial thanks to the straight-forward mocking, which resulted in a faster development of data-fetching components & hooks.
NextJS integration
Apollo has always been a pain for any NextJS major-version bump (worth its own blog-post).
The officially supported next-urql
package has proven to be very reliable, so much so that we encountered zero issues on the next<>Urql integration when upgrading the last 2 major versions (Next 11 and 12).
Conclusion
From a 6 months perspective of having fully migrated from Apollo to Urql we can say with quite a bit of certainty:
If you care about bundle-size, testing & mocking and especially if you use NextJS, assuming your app doesn’t rely on too many of Apollo’s advanced features — Urql is most likely the better option for you.