Skip to content

This package helps you use react-query with a REST API, and get type safety everywhere.

License

Notifications You must be signed in to change notification settings

lifeomic/one-query

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

This package helps you use react-query with a REST API, and get type safety everywhere.

Assumption: it assumes a developer is familiar with react-query's concept, API and its document.

Installation

yarn add @lifeomic/one-query @tanstack/react-query axios

Setup

  1. Define the input + output types for your API endpoints. Use a single type to define the entire set of endpoints.
// endpoints.ts
type Message = {
  id: string;
  content: string;
};

export type APIEndpoints = {
  'GET /messages': {
    Request: { filter?: string };
    Response: Message[];
  };
  'GET /messages/:id': {
    Request: {};
    Response: Message;
  };
  'PUT /messages/:id': {
    Request: { content: string };
    Response: Message;
  };
};
  1. Use createAPIHooks to create re-usable hooks using this type.
// api-hooks.ts
import axios from 'axios';
import { createAPIHooks } from '@lifeomic/one-query';
import { APIEndpoints } from './endpoints'

const hooks = createAPIHooks<APIEndpoints>({ // <-- Specify your custom type here
  name: 'my-api',
  client: axios.create({ baseURL: 'https://my.api.com', headers: {...} })
});

export const {
  useAPIQuery,
  useSuspenseAPIQuery,
  useInfiniteAPIQuery,
  useSuspenseInfiniteAPIQuery,
  useAPIMutation,
  useCombinedAPIQueries,
  useSuspenseCombinedAPIQueries,
  useAPICache,
} = hooks;
  1. Use your hooks. Enjoy auto-completed route names, type-safe inputs, inferred path parameters, and more.
// MyComponent.ts
import { useAPIQuery } from './api-hooks';

const MyComponent = () => {
  const query = useAPIQuery('GET /messages', { filter: '...' });

  const mutation = useAPIMutation('PUT /messages/:id');

  const messages = query.data?.map(m => m.content).join(',');

  return (
    <>
      <div>{messages}<div>
      <button
        onClick={() => {
          mutation.mutate({
            id: 'new-message-id',
            content: 'some message content',
          });
        }}
      >
        Mutate
      </button>
    </>
  );
};
  1. (optional) Use createAPIMockingUtility to create a type-safe network mocking utility for use in unit tests.
// api-mocking.ts
import { createAPIMockingUtility } from '@lifeomic/one-query/test-utils';
import { APIEndpoints } from './endpoints';

export const useAPIMocking = createAPIMockingUtility<APIEndpoints>({
  baseUrl: 'https://my.api.com',
});

Now, use the created utility in your tests to mock the network:

// MyComponent.test.ts
import { render } from '@testing-library/react';
import { useAPIMocking } from './api-mocking';

const api = useAPIMocking();

it('renders messages', () => {
  api.mock('GET /messages', {
    status: 200,
    data: [
      { id: '1', content: 'one' },
      { id: '1', content: 'two' },
    ],
  });

  const view = render(<MyComponent />);

  await view.findByText('one, two')
});

Hooks API Reference

Most of the functionality provided by this library consists of light wrappers around react-query hooks + primitive. These hooks often return values directly from react-query. For complete details on the workings of those internals, see the react-query docs.

The examples in the documentation below will all be based on this example set of endpoints:

type Message = {
  id: string;
  content: string;
};

type APIEndpoints = {
  'GET /messages': {
    Request: { filter?: string };
    Response: Message[];
  };
  'GET /messages/:id': {
    Request: {};
    Response: Message;
  };
  'PUT /messages/:id': {
    Request: { content: string };
    Response: Message;
  };
};

createAPIHooks

Well-typed hooks should first be created using the createAPIHooks helper.

import { createAPIHooks } from '@lifeomic/one-query';

// Passing the explicit generic parameter is important.
// Provide your custom endpoints type as the parameter.
const hooks = createAPIHooks<APIEndpoints>({
  // Provide a unique name for this API. This value is only used internally,
  // to ensure that cached queries are scoped only to this set of created
  // hooks.
  name: 'my-api',
  // Pass an Axios client to use for performing requests.
  client: axios.create({ ... })
});

// `hooks` provides all the hooks you need. We recommend exporting these values from a
// centralized file in your app.
export const {
  useAPIQuery,
  useAPIMutation,
  ...
} = hooks;

If you'd like to define your Axios client in some other way (e.g. using React Context and/or hooks), you can specify a function for getting the client. It is safe to use React hooks within this function -- it will be called according to the rules of hooks:

const hooks = createAPIHooks<APIEndpoints>({
  name: 'my-api',
  client: () => {
    return useMyClient();
  },
});

useSuspenseAPIQuery

Type-safe wrapper around useSuspenseQuery from react-query. Be sure to use within a <React.Suspense /> boundary.

const query = useSuspenseAPIQuery(
  // First, specify the route.
  'GET /messages',
  // Then, specify the payload.
  { filter: 'some-filter' },
);

The return value of this hook is identical to the behavior of the react-query useSuspenseQuery hook's return value.

const query = useQuery('GET /messages', { filter: 'some-filter' });

query.data; // Message[]

Queries are cached using a combination of route name + payload. So, in the example above, the query key looks roughly like ['GET /messages', { filter: 'some-filter' }].

useAPIQuery

Type-safe wrapper around useQuery from react-query.

const query = useAPIQuery(
  // First, specify the route.
  'GET /messages',
  // Then, specify the payload.
  { filter: 'some-filter' },
);

The return value of this hook is identical to the behavior of the react-query useQuery hook's return value.

const query = useQuery('GET /messages', { filter: 'some-filter' });

query.data; // Message[] | undefined

if (query.isPending) {
  return null;
}
if (query.isError) {
  return null;
}

query.data; // Message[]

useSuspenseInfiniteAPIQuery

Type-safe wrapper around useSuspenseInfiniteQuery from react-query

const query = useSuspenseInfiniteAPIQuery(
  'GET /list',
  {
    // after is the token name in query string for the next page to return.
    after: undefined,
  },
  {
    initialPageParam: {},
    // passes the pagination token from request body to query string "after"
    getNextPageParam: (lastPage) => ({ after: lastPage.next }),
    getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }),
  },
);

...

<button
  onClick={() => {
    void query.fetchNextPage();

    // Or fetch previous page
    // void query.fetchPreviousPage();
  }}
/>;

The return value of this hook is identical to the behavior of the react-query useInfiniteQuery hook's return value where data holds an array of pages.

When returning undefined from getNextPageParam it will set query.hasNextPage to false, otherwise it will merge the next api request payload with the returned object, likewise for getPreviousPageParam and query.hasPreviousPage. This is useful to pass pagination token from previous page since the current implementation provides a default queryFn assumes such token is required over query string. It may need another queryFn if the pagination token is managed via headers.

{
  query.data.pages.flatMap((page) => page.items.map((item) => ...));
}

useInfiniteAPIQuery

Type-safe wrapper around useInfiniteQuery from react-query which has a similar api as useQuery with a few key differences.

const query = useInfiniteAPIQuery(
  'GET /list',
  {
    // after is the token name in query string for the next page to return.
    after: undefined,
  },
  {
    initialPageParam: {},
    // passes the pagination token from request body to query string "after"
    getNextPageParam: (lastPage) => ({ after: lastPage.next }),
    getPreviousPageParam: (firstPage) => ({ before: firstPage.previous }),
  },
);

...

<button
  onClick={() => {
    void query.fetchNextPage();

    // Or fetch previous page
    // void query.fetchPreviousPage();
  }}
/>;

The return value of this hook is identical to the behavior of the react-query useInfiniteQuery hook's return value where data holds an array of pages.

When returning undefined from getNextPageParam it will set query.hasNextPage to false, otherwise it will merge the next api request payload with the returned object, likewise for getPreviousPageParam and query.hasPreviousPage. This is useful to pass pagination token from previous page since the current implementation provides a default queryFn assumes such token is required over query string. It may need another queryFn if the pagination token is managed via headers.

{
  query.data.pages.flatMap((page) => page.items.map((item) => ...));
}

useAPIMutation

Type-safe wrapper around useMutation from react-query.

The return value of this hook is identical to the behavior of the react-query useMutation hook's return value. The mutate and mutateAsync values are typed correctly using the endpoint definition

const mutation = useMutation('PUT /messages/:id');

return (
  <button
    onClick={() => {
      mutation.mutate({
        id: 'new-message-id',
        content: 'new message content',
      });
    }}
  >
    Click Me
  </button>
);

useSuspenseCombinedAPIQueries

A helper for combining multiple parallel queries into a single react-query-like hook.

Queries performed using this hook are cached independently, just as if they had been performed individually using useSuspenseAPIQuery.

const query = useSuspenseCombinedAPIQueries(
  ['GET /messages', { filter: 'some-filter' }],
  ['GET /messages/:id', { id: 'some-message-id' }],
);

// Here all queries are complete - pending and error states are handled by suspense and error boundaries

query.data; // [Message[], Message]

const [list, message] = query.data;

list; // Message[]
message; // Message

isFetching

Indicates whether at least one query is in the "fetching" state.

isRefetching

Indicates whether at least one query is in the "refetching" state.

refetchAll()

A helper function for triggering a refetch of every independent query in the combination.

const query = useSuspenseCombinedAPIQueries(
  ['GET /messages', { filter: 'some-filter' }],
  ['GET /messages/:id', { id: 'some-message-id' }],
);

// This:
query.refetchAll();

// Is equivalent to:
for (const individualQuery of query.queries) {
  void individualQuery.refetch();
}

queries

Provides access to the individual underlying queries.

const query = useSuspenseCombinedAPIQueries(
  ['GET /messages', { filter: 'some-filter' }],
  ['GET /messages/:id', { id: 'some-message-id' }],
);

query.queries[0].data; // Messages[]
query.queries[1].data; // Message

useCombinedAPIQueries

A helper for combining multiple parallel queries into a single react-query-like hook. A non-suspense version of useSuspenseCombinedAPIQueries.

Queries performed using this hook are cached independently, just as if they had been performed individually using useAPIQuery.

const query = useCombinedAPIQueries(
  ['GET /messages', { filter: 'some-filter' }],
  ['GET /messages/:id', { id: 'some-message-id' }],
);

// This means _at least one_ query is in the "error" state.
if (query.isError) {
  return;
}

// This means _at least one_ query is in the "loading" state.
if (query.isPending) {
  return;
}

// Now, we know that all queries are complete.

query.data; // [Message[], Message]

const [list, message] = query.data;

list; // Message[]
message; // Message

isFetching

Indicates whether at least one query is in the "fetching" state.

isRefetching

Indicates whether at least one query is in the "refetching" state.

isPending

Indicates whether at least one query is in the "pending state.

isError

Indicates whether at least one query is in the "error" state.

refetchAll()

A helper function for triggering a refetch of every independent query in the combination.

const query = useCombinedAPIQueries(
  ['GET /messages', { filter: 'some-filter' }],
  ['GET /messages/:id', { id: 'some-message-id' }],
);

// This:
query.refetchAll();

// Is equivalent to:
for (const individualQuery of query.queries) {
  void individualQuery.refetch();
}

queries

Provides access to the individual underlying queries.

const query = useCombinedAPIQueries(
  ['GET /messages', { filter: 'some-filter' }],
  ['GET /messages/:id', { id: 'some-message-id' }],
);

query.queries[0].data; // Messages[] | undefined
query.queries[1].data; // Message | undefined

useAPICache

This hook provides several utilities for doing well-typed cache invalidation + cache updates for queries.

invalidateQueries

Performs invalidaton of queries using react-query's invalidateQueries.

const cache = useAPICache();

// Invalidates _all_ queries targeting the "GET /messages" route,
// regardless of payload.
cache.invalidateQueries({
  'GET /messages': 'all',
});

// Invalidates any queries targeting the "GET /messages" route that
// have the payload `{ filter: 'some-filter' }`
cache.invalidateQueries({
  'GET /messages': [{ filter: 'some-filter' }],
});

// Invalidates any queries targeting the "GET /messages" route that
// have _either_ of the specified payloads.
cache.invalidateQueries({
  'GET /messages': [{ filter: 'some-filter' }, { filter: 'other-filter' }],
});

// Provide a predicate if you need to match queries programmatically.
cache.invalidateQueries({
  'GET /messages': (payload) => {
    return payload.filter.includes('some-');
  },
});

// Pass multiple keys to invalidate multiple queries at once.
cache.invalidateQueries({
  'GET /messages': 'all',
  'GET /messages/:id': [{ id: 'some-id' }],
});

resetQueries

Resets queries using react-query's resetQueries.

The API is identical to invalidateQueries.

const cache = useAPICache();

cache.resetQueries({
  'GET /messages': 'all',
});

updateCache

Performs surgical, well-typed updates to cached queries.

const cache = useAPICache();

cache.updateCache(
  // Specify the route + payload that you'd like to update the cached value for.
  'GET /messages',
  { filter: 'some-filter' },
  // Then, specify the updated value.
  [
    { id: '1', content: 'message content one' },
    { id: '2', content: 'message content two' },
  ],
);

// Or, perform a programmatic update by transforming the current cached value:
cache.updateCache(
  'GET /messages',
  { filter: 'some-filter' },
  // `current` will be the current value in the cache.
  (current) => {
    // It's safe to simply mutate `current` directly and return nothing
    //  to perform an update.
    current.push({ id: 'message-id-new', content: 'new message content' });

    // OR, you can just return an updated value if you prefer
    return [
      ...current,
      { id: 'message-id-new', content: 'new message content' },
    ];
  },
);

When dealing with a cache entry that was initiated via useInfiniteAPIQuery (paginated) prefer using updateInfiniteCache which otherwise behaves the same as updateCache.

const cache = useAPICache();

cache.updateInfiniteCache(
  'GET /messages',
  { filter: 'some-filter' },
  (current) => {...},
);

getQueryData

Get the cached data for a query, if there is any.

const cache = useAPICache();

const value = cache.getQueryData(
  // Specify the route + payload that you'd like to get the cached value for.
  'GET /messages',
  { filter: 'some-filter' },
);

value; // Message[] | undefined

When dealing with a cache entry that was initiated via useInfiniteAPIQuery (paginated) prefer using getInfiniteQueryData which otherwise behaves the same as getQueryData.

const cache = useAPICache();

const value = cache.getInfiniteQueryData('GET /messages', {
  filter: 'some-filter',
});

value; // { pages: Message[]; }

getQueriesData

Get the cached data for every query of the provided route, by payload.

const cache = useAPICache();

// Specify the route.
const value = cache.getQueriesData('GET /messages');

value; // { payload: { filter: string; }; data: Message[] | undefined; }[]

If you want to fetch cache data created by useInfiniteAPIQuery, prefer using getInfiniteQueriesData which otherwise behaves the same as getQueriesData.

const cache = useAPICache();

const value = cache.getInfiniteQueriesData('GET /messages');

value; // { payload: { filter: string; }; data: { items: } | undefined; }

Test Utility API Reference

one-query also provides a testing utility for doing type-safe mocking of API endpoints in tests. This utility is powered by msw.

createAPIMockingUtility(...)

If you're using jest for testing, use createAPIMockingUtility to create a shareable utility for mocking network calls.

import { setupServer } from 'msw/node';

// Set up your server, and start listening.
const server = setupServer();
server.listen({ onUnhandledRequest: 'error' });

// Specify your custom "APIEndpoints" type as the generic parameter here.
export const useAPIMocking = createAPIMockingUtility<APIEndpoints>({
  server,
  baseUrl: 'https://my.api.com',
});

// another-file.ts
const api = useAPIMocking();

// now, any mocks applied during tests will be automatically cleaned up + cleared
// before each test
test('something', () => {
  api.mock(...)
});

createAPIMocker(...)

This lower-level function is useful for creating a mocking utility if any of the following are true:

  • You are not using jest as a test runner
  • You need to mock more than one API

If none of the above are true, use createAPIMockingUtility(...).

import { setupServer } from 'msw/node';

// We recommend building your own utility, like this:
export const useAPIMocking = () => {
  const server = setupServer();

  server.listen({ onUnhandledRequest: 'error' });

  // Specify your custom "APIEndpoints" type as the generic parameter here.
  const mocker = createAPIMocker<APIEndpoints>(server, baseUrl);

  beforeEach(() => {
    mocker.reset();
  });

  afterAll(() => {
    server.close();
  });

  return mocker;
};

mock(route, mocker)

Mocks the specified route with the specified mocker persistently. The provided mock response will be used indefinitely until it is removed or replaced.

const api = useAPIMocking();

api.mock(
  // First, specify the route name.
  'GET /messages',
  // Then specify a static response.
  {
    status: 200,
    data: [
      { id: '1', content: 'one' },
      { id: '2', content: 'two' },
    ],
  },
);

// The mock is persistent -- multiple call will receive the same result:
await axios.get('/messages');
// [{ id: '1', ...}, { id: '2', ... }]
await axios.get('/messages');
// [{ id: '1', ...}, { id: '2', ... }]

// A function can also be used to respond programmatically:
api.mock('GET /messages', (req) => ({
  status: 200,
  data:
    req.query.filter === 'some-filter'
      ? [{ id: '1', content: 'one' }]
      : [
          { id: '1', content: 'one' },
          { id: '2', content: 'two' },
        ],
}));

await axios.get('/messages');
// [{ id: '1', ...}, { id: '2', ... }]
await axios.get('/messages?filter=some-filter');
// [{ id: '1', ...}]

The function-style mocking can also be useful for making specific assertions about network calls.

const getMessages = jest.fn();
api.mock(
  'GET /messages',
  getMessages.mockReturnValue({
    status: 200,
    data: [],
  }),
);

await axios.get('/messages?filter=some-filter');

expect(getMessages).toHaveBeenCalledTimes(1);
expect(getMessages).toHaveBeenCalledWith(
  expect.objectContaining({
    query: 'some-filter',
  }),
);

mockOrdered(route, responses)

Mocks a series of ordered responses from the specified route.

const api = useAPIMocking();

api.mockOrdered('GET /messages', [
  { status: 200, data: [] },
  { status: 200, data: [{ id: '1', content: 'one' }] },
  {
    status: 200,
    data: [
      { id: '1', content: 'one' },
      { id: '2', content: 'two' },
    ],
  },
]);

await client.get('/messages');
// []

await client.get('/messages');
// [{ id: '1', content: 'one' }]

await client.get('/messages');
// [{ id: '1', content: 'one' }, { id: '2', content: 'two' }]

await client.get('/messages');
// This request will *not* be mocked.

About

This package helps you use react-query with a REST API, and get type safety everywhere.

Resources

License

Stars

Watchers

Forks

Packages

No packages published