Typescript and React-Query for api usage.
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?
Overview | TanStack Query React Docs
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 Can't run commitlint on project with tsconfig.json · Issue #3256 · conventional-changelog/commitlint
# 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.
2: Define API hooks
3: Define Selectors for the API hooks
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:
NOT this:
Testing
API methods
For API selector methods, you want to verify 4 things:
Does it call
useQuery
with the correctqueryKey
array?Does it call
useQuery
with the expected fetchqueryFn
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
For mutations, we really just need to validate the mutation function, so in much the same way:
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.