Wrapping React Query's useQuery (A Use Case for Wrapping External Libraries)

React Query is a library for fetching and mutating server state via React hooks. In addition to the perk of caching, it also neatly returns metadata representing the various lifecycles of a network request:

const {
   data,
   isError,
   isFetched,
   isLoading,
   ...etc,
 } = useQuery('todos', getTodos);

This cuts down on the boilerplate when using React local state to track this metadata manually.

As shown in the example above, the useQuery hook takes in a "query key" (the key for the data in the cache) and a function that "queries" data via an API.

There are possibilities to improve the signature of this hook.

First, there is currently no way of enforcing that all "queries" go through the same API client.

For example, you could have one instance of useQuery that uses the native fetch API and another that uses a custom fetch wrapper:

// some-component.js

const result = useQuery('cars', () => {
  const resp = await fetch('/api/v1/cars', { method: 'GET' });
  return await resp.json();
});

// another-component.js
import fetchClient from './fetch-client';

const result = useQuery('cars', async () => {
  const resp = await fetchClient('/api/v1/cars');
  return await resp.json();
});

Given this example, there is a code smell since fetchClient is the intended way to make API requests as it encapsulates logic, error handling, preferred settings, etc.

To improve upon this, we can come up with design patterns that help enforce the reusing of the same fetch client.

One option is to export the custom fetch client and all the modules of React Query from a single file, avoiding the importing/using of React Query directly:

// api.js

const defaultOptions = { method: 'GET' };
export async function fetcher(url, options = defaultOptions) {
  const resp = await fetch(url, options);
  return await resp.json();
}

export * from 'react-query';

// some-component.js
import { fetcher, useQuery } from './api.js';

const result = useQuery('cars', async () => {
  return await fetcher('/api/v1/cars');
});

Alternatively, we may expose the fetcher via a hook (similar to React Redux's useDispatch):

// api.js

const defaultOptions = { method: 'GET' };
async function fetcher(url, options = defaultOptions) {
  const resp = await fetch(url, options);
  return await resp.json();
}

export function useFetcher() {
  return fetcher;
}

export * from 'react-query';

// some-component.js
import { useFetcher, useQuery } from './api.js';

const fetcher = useFetcher();
const result = useQuery('cars', async () => {
  return await fetcher('/api/v1/cars');
});

As a third option, we could conceal the fetcher in a wrapper around useQuery:

// api.js
import { useQuery as baseUseQuery } from 'react-query';

const defaultOptions = { method: 'GET' };
async function fetcher(url, options = defaultOptions) {
  const resp = await fetch(url, options);
  return await resp.json();
}

function useQuery(queryKey, query) {
  return useBaseQuery(queryKey, async () => {
    return await fetcher(query);
  });
}

// some-component.js
import { useQuery } from './api.js';

const result = useQuery('cars', '/api/v1/cars');

The second limitation of the plain useQuery can be seen most clearly in our latest wrapper.

Here, we can predict that the "query key" is likely a subpath on the API route.

Because of that, we can derive the "query key" from the query in our abstraction:

// api.js
import { kebabCase } from 'lodash';
import { useQuery as baseUseQuery } from 'react-query';

const defaultOptions = { method: 'GET' };
async function fetcher(url, options = defaultOptions) {
  const resp = await fetch(url, options);
  return await resp.json();
}

function useQuery(query) {
  return useBaseQuery(kebabCase(query), async () => {
    return await fetcher(`/api/v1/${query}`);
  });
}

// some-component.js
import { useQuery } from './api.js';

const result = useQuery('cars');

🎉 Just like that we've simplified our API lifecycles by wrapping useQuery to better fit our needs.

Regardless of whether this wrapper (as demonstrated) suits your preferences and needs, I hope it helps show the potential value of wrapping modules from shared libraries.

35