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:
Presentational logic/components
Internal/local business logic
business logic around external API data
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
Install the alpha branch of frontend build
npm install @edx/frontend-build@alpha
Add a
tsconfig.json
file to the top-level of your app{ "extends": "@edx/typescript-config", "compilerOptions": { "rootDir": ".", "outDir": "dist", "baseUrl": "./src", "paths": { "*": ["*"] } }, "include": ["src/**/*"], "exclude": ["dist", "node_modules"] }
Update commitlint job (
.github/workflows/commitlint.yml
) to remove thetsconfig
when runningSee issue https://github.com/conventional-changelog/commitlint/issues/3256
# Run commitlint on the commit messages in a pull request. name: Lint Commit Messages on: - pull_request jobs: commitlint: runs-on: ubuntu-20.04 steps: - name: Check out repository uses: actions/checkout@v3 with: fetch-depth: 0 - name: remove tsconfig.json # see issue https://github.com/conventional-changelog/commitlint/issues/3256 run: | rm -f tsconfig.json - name: Check commits uses: wagoid/commitlint-github-action@v5
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 }
Step 2: React-Query types
Add query types for react-query
queries.
Note: react-query does provide exported types here, but in general I found those a bit deeper/more complex than needed for this basic usage. In particular, I am relying on a snake_case upstream data source and converting it into camelCase for consumption. The TS wrapping of the useQuery
calls in this pattern is not optimized for that specific interface, so much as it is for the consuming selectors.
// .../api-src/types.ts ... // React-Query fields export interface QueryStatus { isLoading: boolean, isFetching: boolean, isInitialLoading: boolean, error: unknown, status: string, } export interface QueryData<Response> extends QueryStatus { data: Response, }
3: 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> => { const { data, ...status } = useQuery({ queryKey: [queryKeys.userData, userId], queryFn: () => fetch(`my-urls/user-data/${userId}`), }); return { ...status, data: data ? camelCaseObject(data) : {} }; }; export const useSystemData = (): types.QueryData<types.SystemData> => { const { data, ...status } = useQuery({ queryKey: [queryKeys.systemData], queryFn: () => fetch('my-urls/system-data'), }); return { ...status, data: data ? camelCaseObject(data) : {} }; }; export const useSubmit = () => useMutation({ mutationFn: (data: types.SubmitEventData), });
4: 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. */
5: 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:
Does it call
useQuery
with the correct queryKey array?Does it call
queryFn
with the expected fetch method?Does it return the status fields from they query?
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? it('returns camelCase object from data if data has been returned', () => { expect(out.data).toEqual(camelCaseObject(response)); }); // Does it return camel-case’d data response from the query (or {} if empty)? 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.