From Apollo to Urql — Part 2

Daniel Zlotnik
Sesame Engineering
Published in
7 min readApr 12, 2022
Photo by Umberto on Unsplash

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.

--

--