Objective

The purpose of this document/presentation is to lay out a usage pattern for using React-Query and Typescript (TS) to wrap API usage in a react app. This pattern explicitly deals with updating only the API code to typescript, while leaving the rest as generic JavaScript.

Why limit typescript to API code?

The goal of This pattern is to provide a light-weight entry-point for an app to include Typescript where it will provide the largest value.

In general terms, one can break React code into 3 categories:

Of these categories, Presentational Components come bundled in React with prop-types, which provides type-checking for those components.

Internal business logic is actually a pretty valuable place to wrap TS as well, but is more likely to be more tightly intwined with the internals of an already-crafted app, and thus a harder starting point.

However, external API data logic is referencing shapes that are otherwise not (necessarily) locally referenced, and as this data should be fairly static, it should be much easier to update in isolation. Adding Typescript around this code provides build-time validation that consumers of the API are consuming it appropriately.

Why react-query?

https://tanstack.com/query/latest/docs/react/overview

As we are wrapping our API data in Typescript, we are also taking steps to separate data that is static (from an external source) from data that is directly tied to the UI logic. react-query provides a simple and clean wrapper for API events, while also separating out this static API data fetching, storage, and access from redux.

Caveats

Caveat 1 - Aimed at edx MFE packages

This document is aimed specifically at consumers of the edx build toolchain, based on @edx/frontend-build. This package has an alpha branch with Typescript support and dependencies, including updating a babel build. If you are not consuming the edx toolchain for your build process, you will need to install Typescript to go into your linting/babel system appropriately.

Caveat 2 - Alpha branch

The consuming repos so far require a --legacy-peer-deps argument for npm install and npm ci calls while supporting this branch, as there are not matching versions in the other packages in our tool-chain. Unfortunately, this does leave us open to hiding and not fixing other peer dependency issues. We are working as quickly as possible to get that alpha branch merged into master to remove this requirement.

Note: @edx/react-unit-test-utils

This presentation will make use of the @edx/react-unit-test-utils library for a number of utilities.

Most notably, you wills see a StrictDict referenced, that is just a wrapper for objects that complains when called with an invalid/missing key. These are useful for key-stores for testing and validation.

Typescript App Setup

Typescript-ifying Your API

AKA the Fun Part

Step 1: API Design (list and define data shapes for requests/events)

Come up with a list of unique fetch events you want and give them each a unique identifier.

// .../api-src/constants.js
import { StrictDict } from '@edx/react-unit-test-utils'

const queryKeys = StrictDict({
  userData: 'userData',
  systemData: 'systemData',
});

export default { queryKeys };

Then, based on the API definitions, write Typescript definitions for the response types.

Note: I am not writing example definitions here as those will heavily depend on your specific api shape.

Tip: Where possible, try to break into sub-interfaces for easier typing of UI selectors later.

// .../api-src/types.ts
export interface UserData { ...myUserDataTypeDefinition }
export interface SystemData { ...mySystemDataTypeDefinition }
export interface SubmitEventData { ...mySubmitEventDataTypeDefinition }

2: Define API hooks

// .../api-src/hooks/api.ts
import { useQuery, useMutation } from '@tanstack/react-query';

import { camelCaseObject } from '@edx/frontend-platform'; // or lodash
import * as types from '../types';
import { queryKeys } from '../constants';

export const useUserData = (userId: string): types.QueryData<types.UserData> => (
  useQuery({
    queryKey: [queryKeys.userData, userId],
    queryFn: () => fetch(`my-urls/user-data/${userId}`)
      .then(camelCaseObject),
  })
);

export const useSystemData = (): types.QueryData<types.SystemData> => (
  useQuery({
    queryKey: [queryKeys.systemData],
    queryFn: () => fetch('my-urls/system-data')
      .then(camelCaseObject),
  });
);

export const useSubmit = () => useMutation({
  mutationFn: (data: types.SubmitEventData),
});

3: Define Selectors for the API hooks

// ...api-src/hooks/selectors.ts
import * as api from './api';
import * as types from './types';

// generic accessor
export const useUserDataStatus = (userId: string): types.QueryStatus => {
  const {
    isLoading,
    isFetching,
    isInitialLoading,
    status,
    error,
  } = api.useUserData(userId);
  return {
    isLoading,
    isFetching,
    isInitialLoading,
    status,
    error,
  };
};

export const useIsUserDataLoaded = (userId: string): boolean => (
  api.useUserData().status === 'success'
);
export const useUserDataResponse = (userId: string): types.UserData => (
  api.useUserData().data
);

/*
 * add more, increasingly granular UI selectors here, until all "questions"
 * asked about the data by the UI for presentation are answered in this module.
 */

4: Consume (Profit!)

When consuming data, ALWAYS consume with selectors. The reasoning is that this separates your UI code from the raw details of the incoming data. At all times, the consumers of the data should be asking “Questions” that can be moved around and fixed, even if the incoming data shape changes.

Do this:

import { useSubmit } from '.../api-src/hooks/api';
import {
  useUserDataResponse,
  useIsUserDataLoaded,
  ...otherSelectors,
} from '.../api-src/hooks/selectors'
useMyAppComponentData = (userId) => {
  const response = useUserDataResponse(userId);
  const isLoaded = useIsUserDataLoaded(userId);
  const submit = useSubmit();
  return { submit, myUserData: response, isLoaded };
}

NOT this:

import { useUserData, useSubmit } from '.../api-src/hooks/api';
useMyAppComponentData = (userId) => {
  const myUserData = useUserData(userId).data;
  const submit = useSubmit();
  return { submit, myUserData };
}

Testing

API methods

For API selector methods, you want to verify 4 things:

  1. Does it call useQuery with the correct queryKey array?

  2. Does it call useQuery with the expected fetch queryFn method?

  3. Does it return the status fields from they query?

  4. Does it return camel-case’d data response from the query (or {} if empty)?

To assist in this, we can mock react-query with jest and jest-when

// ...api-src/hooks/api.test.ts
import { useCamelCaseObject } from '@edx/frontend-platform';
import { when } from 'jest-when';

jest.mock('@tanstack/react-query', () => ({
  useQuery: jest.fn()
  useMutation: jest.fn(({ mutationFn }) => ({ mutationFn })),
}));

const mockUseQueryForUserData = (response) => {
  when(useQuery)
    .calledWith(expect.objectContaining({ queryKey: [queryKeys.userData] }))
    .mockImplementation(({ queryKey, queryFn }) => ({
      data: response,
      queryKey,
      queryFn,
    });
}
let out;
const userId = 'test-user-id';
const response = { test-field: 'user-response' };
describe('api hooks', () => {
  describe('useUserData', () => {
    beforeEach(() => {
      mockUseQueryForUserData(response);
      out = hooks.useUserData(userId);      
    });
    
    // Does it call useQuery with the correct queryKey array?
    it('initializes query with userData queryKey and user ID', () => {
      expect(out.queryKey).toEqual([queryKeys.userData, userId]);
    });
    
    // Does it call queryFn with the expected fetch method?
    it('initializes query with <my get behavior>', () => {
      // Mock/test your fetch method by calling out.queryFn()
    });

    // Does it return the status fields from they query?
    -- Add status test from repo
    
    // Does it return camel-case’d data response from the query (or {} if empty)?
    it('returns camelCase object from data if data has been returned', () => {
      expect(out.data).toEqual(camelCaseObject(response));
    });
    
    it('returns empty object from data if data has not been returned', () => {
      mockUseQueryForUserData(undefined);
      out = hooks.useUserData(userId);
      expect(out.data).toEqual({});
    })
  });
  // ...test system data in similar pattern.
});

For mutations, we really just need to validate the mutation function, so in much the same way:

// ...api-src/hooks/api.test.ts
import { useCamelCaseObject } from '@edx/frontend-platform';
import { when } from 'jest-when';

jest.mock('@tanstack/react-query', () => ({ useMutation: jest.fn() }));

let out;
const response = { test-field: 'user-response' };
describe('api hooks', () => {
  describe('useSubmit', () => {
    beforeEach(() => {
      out = hooks.useSubmit;      
    });
    ...test the passed event as you would normally validate an api event
  });
});

Conclusion

Typescript is a powerful tool for an untyped language like JavaScript, as it allows the definition of the code itself to document its types (and raise errors when consumed incorrectly). Especially for API code, this provides an important level of stability/maintainability when connecting a UI to an external data shape.

Unfortunately, updating an entire app to use it can be extremely taxing from an engineering standpoint which tends to heavily raise the barrier to entry for using this very valuable technology.

The pattern laid out in this document aims to provide a light-weight entry-point that fully isolates the new TypeScript implementation in a hopefully-already-isolated api, while also allowing a removal of the redux middle-man tracking the api data as if it were a live malleable object, in favor of a static data-selection-based interface.