During front-end development, we may spend a lot of time integrating apis, interfacing with apis, or solving problems caused by API changes. If you want to lighten that burden and make your team more productive, this article will definitely help.
The main technology stacks used in this article are:
- The React buckets
- TypeScript
- RxJS
This article describes some of the complex scenarios encountered when integrating apis and provides solutions. Through the small tools written by myself, the API integration code is automatically generated, which greatly improves the development efficiency of the team.
All the code for this article is in this repository: Request.
The tool for automatically generating code is here: TS-CodeGen.
1. Process HTTP requests in a unified manner
1.1 Why do I do this?
We can make HTTP requests directly via FETCH or XMLHttpRequest. However, if you do this in every place the API is called, you can generate a lot of template code, and some business scenarios are difficult to deal with:
-
How to add loading animations for all requests?
-
How do I display error messages after a request failure?
-
How to implement API de-duplication?
-
How do I track requests through Google Analytics?
Therefore, in order to reduce template code and deal with complex business scenarios, HTTP requests need to be handled uniformly.
1.2 How to design and implement?
With Redux, we can “action” API requests. In other words, convert API requests into actions in Redux. Typically, an API request is converted into three different actions: Request Action, Request Start Action, and Request Success /fail Action. They are used to initiate API requests and record the status of request start, request successful response, and request failure. We can then implement different Middleware to handle these actions for different business scenarios.
1.2.1 the Request Action
Redux’s Dispatch is a synchronization method that is only used to distribute actions (plain objects) by default. With Middleware, we can dispatch anything, like functions (redux-thunk) and Observables, just to make sure they’re blocked.
To implement asynchronous HTTP requests, we need a special kind of action, called the Request Action in this article. The Request Action carries information about the request parameters for later use when making HTTP requests. Unlike other actions, it requires a Request attribute as an identifier. Its definition is as follows:
interface IRequestAction<T = any> {
type: T
meta: {
request: true // mark request action
};
payload: AxiosRequestConfig; // Request parameters
}
Copy the code
Redux’s actions have been criticized for generating a lot of template code and being prone to miswriting pure string types. Instead of using the Action object directly, we use the Action Creator function to generate the corresponding action. The community’s Redux-Actions, for example, helps us create an Action Creator. Reference for its implementation, we can implement a function createRequestActionCreator, used to create the following definition of action creator:
interfaceIRequestActionCreator<TReq, TResp = any, TMeta = any> { (args: TReq, extraMeta? : TMeta): IRequestAction; TReq: TReq;// The type of the request parameter
TResp: TResp; // Request response type
$name: string; // Name of the request Action Creator function
toString: (a)= > string;
start: {
toString: (a)= > string;
};
success: {
toString: (a)= > string;
};
fail: {
toString: (a)= > string;
};
}
Copy the code
In the above code, TReq and TResp represent the type of the request parameter and the type of the request response, respectively. They are stored in the prototype of the Request Action Creator function. This way, with Request Action Creator, we can quickly know the type of an API request parameter and the type of the response data.
const user: typeof getUser.TResp = { name: "Lee", age: 10 };
Copy the code
For API requests, the nodes of request start, request success, and request failure are important. Because each node can trigger UI changes. We can define three specific types of actions to record each asynchronous phase. Request start action, Request Success Action and Request Fail Action are defined as follows:
interface IRequestStartAction<T = any> {
type: T; // xxx_START
meta: {
prevAction: IRequestAction; // Save the corresponding reqeust action
};
}
interface IRequestSuccessAction<T = any, TResp = any> {
type: T; // xxx_SUCCESS
payload: AxiosResponse<TResp>; // Save API Response
meta: {
prevAction: IRequestAction;
};
}
interface IRequestFailAction<T = any> {
type: T; // xxx_FAIL
error: true;
payload: AxiosError; / / save the Error
meta: {
prevAction: IRequestAction;
};
}
Copy the code
In the code above, we bind the toString method to the request Action Creator prototype, along with the start, Success, and Fail properties. Since action Types are pure strings, handwriting is error-prone, so we want to get their types directly from Request Action Creator, like this:
`${getData}` // "GET_DATA"
`${getData.start}` // "GET_DATA_START"
`${getData.success}` // "GET_DATA_SUCCESS"
`${getData.fail}` // "GET_DATA_FAIL"
Copy the code
1.2.2 Request Middleware
Next, we need to create middleware to process request actions uniformly. Middleware’s logic is as simple as intercepting all request actions and making HTTP requests:
- Request start: dispatch xxx_STAT action to display loading
- Request successful: with API Response, dispatch xxx_SUCCESS action
- Request failed: Dispatch xxx_FAIL Action with Error message
It’s important to note that Request middleware “eats” request action, meaning it doesn’t pass that action on to downstream middleware for processing. One is because the logic is already being processed in this middleware, and downstream middleware doesn’t need to process such actions. Second, if middleware downstream also dispatches request actions, it creates an endless loop, causing unnecessary problems.
1.3 How to Use it?
We can trigger the invocation of the request by distributing the Request Action. Then the request success action is processed in the Reducer, and the response data of the request is stored in the Redux store.
However, many times we not only make API requests, but also perform some logic when the request succeeds and when the request fails. These logic do not affect state and therefore do not need to be processed in the Reducer. For example: the user fills in a form and clicks the Submit button to initiate an API request. When the API request is successful, the page jumps. This problem is easily solved with promises, where you just put logic into its then and catch. However, once the request is “actionized,” we cannot register the success and failure of the request at the same time we invoke it, as Promise does.
How to solve this problem? We can implement a promise-like invocation that allows us to register successful and failed callback requests while distributing the Request action. UseRequest, which we’re going to introduce.
1.3.1 useRequest: Invoke the request based on React Hooks and RXJS
To keep the initiation, success, and failure phases of the request separate, we designed the onSuccess and onFail callbacks. Then and catch, similar to promises. You want to be able to trigger an API request like this:
/ / pseudo code
useRequest(xxxActionCreator, {
onSuccess: (requestSuccessAction) = > {
// do something when request success
},
onFail: (requestFailAction) = > {
// do something when request fail}});Copy the code
Callback for success and failure of a request is handled through RxJS
Promises and callbacks can’t be undone once they’ve been implemented. It can be awkward if you have to “cancel” a scene. There are ways to get around this, but the code is still not elegant enough. Therefore, we introduced RxJS to try to explore and solve this problem in a new way.
We can modify Redux’s dispatch method to dispatch a Subject $(observer) each time we dispatch an action. Next, create a rootSubject$(observable) in middleware that intercepts a Subject$sent by dispatch and makes it an observer of rootSubject$. RootSubject $pushes the action from dispatch to all its observers. Therefore, simply observe the successful and failed actions of the request and execute the corresponding callback.
With the nature of Rx itself, we can easily control complex asynchronous processes, including cancellation.
Implement useRequest Hook
UseRequest provides a function to distribute the Request action and to execute the corresponding callback function when the request succeeds or fails. Its inputs and outputs are roughly as follows:
interfaceIRequestCallbacks<TResp> { onSuccess? :(action: IRequestSuccessAction<TResp>) = > void; onFail? :(action: IRequestFailAction) = > void;
}
export enum RequestStage {
START = "START",
SUCCESS = "SUCCESS",
FAILED = "FAIL",}const useRequest = <T extends IRequestActionCreator<T["TReq"], T["TResp"]>>(
actionCreator: T,
options: IRequestCallbacks<T["TResp"]> = {},
deps: DependencyList = [],
) => {
// ...
return [request, requestStage$] as [typeof request, BehaviorSubject<RequestStage>];
};
Copy the code
It takes actionCreator as the first argument and returns a request function that, when called, dispense the corresponding Request action to initiate the API request.
It also returns an observable, requestStage$, which pushes the stage of the current request. There are three phases: request start, success and failure. This way, after the request is initiated, we can easily track its status. This is useful in scenarios such as displaying a loading animation on the page when the request starts and closing it when the request ends.
Why return the observable requestStage$instead of the requestStage state? If the state is returned, it means that setState is required at request start, request success, and request failure. But not every scenario requires this state. For components that do not need this state, there is some waste (re-render). So, we return an observable, and when you need that state, subscribe to it.
Options as its second argument, you can specify the onSuccess and onFail callbacks. OnSuccess will provide you with the Request Success action as a parameter, which you can use to get the data after the successful response to the request. You can then choose to store the data in the Redux Store, or in the Local State, or you don’t care about its response data and just jump to the page if the request succeeds. However, useRequest makes it much easier to implement requirements.
const [getBooks] = useRequest(getBooksUsingGET, {
success: (action) = > {
saveBooksToStore(action.payload.data); // Store response data in redux store}});const onSubmit = (values: { name: string; price: number }) = > {
getBooks(values);
};
Copy the code
A complex scenario
UseRequest encapsulates the logic for invoking the request, and by combining multiple Userequests, you can handle many complex scenarios.
Process multiple Request actions that are independent of each other
Initiate multiple request actions at the same time. These request actions are independent of each other. This case is as simple as using multiple Userequests.
const [requestA] = useRequest(A);
const [requestB] = useRequest(B);
const [requestC] = useRequest(C);
useEffect((a)= >{ requestA(); requestB(); requestC(); } []);Copy the code
Process multiple Request actions that are related to each other
Initiate multiple request actions at the same time, in order of precedence. For example, request A is sent, request B is sent after request A is successful, and request C is sent after request B is successful.
Since useRequest creates the function that initiates the request and executes the onSuccess callback after the request succeeds. Therefore, we can create multiple request functions with useRequest and set the logic for their successful response. Just like RXJS’s “pre-pipe”, when an event occurs, the system will follow the preset pipe.
// Create all request functions in advance and preset the logic of onSuccess
const [requestC] = useRequest(C);
const [requestB] = useRequest(B, {
onSuccess: (a)= >{ requestC(); }});const [requestA] = useRequest(A, {
onSuccess: (a)= >{ requestB(); }});// When the requestA is actually called, the program will execute according to the preset logic.
<form onSubmit={requestA}>
Copy the code
Process multiple identical request actions
Launch multiple identical Request actions at the same time, but for performance reasons, we usually “eat” the same action, with only the last one making the API request. This is the API de-duplication we mentioned earlier. However, there are two different requirements for the request Action callback function:
- The onSuccess/onFail callback corresponding to each of the same request actions is executed when the request succeeds.
- Only the onSuccess/onFail callback corresponding to the action that actually initiated the request is executed.
For the first scenario, we can determine whether the type of the action is the same as the payload. If the action type is the same as the payload, we can execute the corresponding callback, so that the callback of the same action can be executed. For the second scenario, we can add a UUID to the payload of the action. The payload of the action contains the request config that we need to send a request. You can make this “same” action “different” from other actions so that only the callback function corresponding to the request action is executed.
Component uninstall
Usually we make API requests using Promises or XMLHttpRequest, but since API requests are asynchronous, their callbacks will still be executed after the components are unloaded. This can cause problems such as setState execution in unloaded components.
After a component is uninstalled, the logic inside the component should be “destroyed” and we should not execute any logic contained in any component. With RxJS, useRequest can automatically cancel all logic when a component is destroyed. In other words, no more successful or failed callback is executed.
2. Store and use the request response data
How do we store data like API Response? As different API Response data have different effects on applications, we can abstract corresponding data models and store them by classification. As we receive daily necessities, the first drawer put tableware, the second drawer put snacks……
According to the frequency of data changes, or the survival time of data, API response can be roughly divided into two categories:
One type of data is data that changes very frequently, such as leaderboard lists, which may change from second to second. This type of data has no cache value and is called temporary data. Temporary data is destroyed when it is used up.
The other type of data is data that doesn’t change very often. We call it entity data, such as a list of countries, a list of brands. This type of data often needs to be cached locally, and it is easier to persist data by grouping it into one category.
2.1 useTempData
2.1.2 background
With useRequest we have been able to easily invoke API requests. But for most business scenarios, it can be cumbersome. Consider a very common requirement: to render API data to a page. We usually need the following steps:
Step1: Dispatch a Request action when the component mounts. This can be done with useRequest.
Step2: process the request success action and store the data in the store.
Step3: pick the corresponding data from the store state and provide it to the component.
Step4: the component takes the data and renders the page.
Step5: after performing some operations, re-initiate the request with the new request parameters.
Step6: Repeat Step2, Step3, Step4.
If every integration API had to go through these steps, it would not only waste a lot of time, but also produce a lot of template code. Also, because the logic is so fragmented, we can’t add tests to them uniformly, so we need to test them individually for each place we use them. As one can imagine, development efficiency will be greatly compromised.
To solve this problem, we abstract useTempData. We’ve already mentioned the concept of Temp data, which refers to temporary data on a page, usually “disappearing after reading”. Most of the data obtained through API requests on our project falls into this category. UseTempData is primarily used to automatically obtain API data when a component is mounted and to automatically destroy it when the component is unmounted.
2.1.3 Input and Output
UseTempData automatically distributes the Request action when the component mounts, stores the response data to the Redux store when the request succeeds, extracts the response data from the Store, and makes the response data available for external use. Of course, you can also configure useTempData to respond to changes in request parameters. When the request parameters change, useTempData will re-initiate the request with the new request parameters.
Its core input and output are as follows:
export const useTempData = <T extends IRequestActionCreator<T["TReq"], T["TResp"]>>( actionCreator: T, args? : T["TReq"],
deps: DependencyList = [],
) => {
// ...
return [data, requestStage, fetchData] as [
typeof actionCreator["TResp"].typeof requestStage,
typeof fetchData,
];
};
Copy the code
It takes actionCreator as the first parameter to create the corresponding Request action. Request Action is automatically distributed when the component is mounted. Args is used as the second parameter to set the request parameters. Deps is the third argument, and when it changes, the Request Action is redistributed.
It also returns data for the API response, requestStage representing the current stage of the request, and fetchData, the function used to distribute the Request action.
It is also very easy to use, if the business scenario is simple, the integration API is a one-line affair:
const [books] = useTempData(getBooksUsingGET, { bookType }, [bookType]);
// Get the books data and render the UI
Copy the code
2.1.4 Implementation Idea
UseTempData is implemented based on useRequest. Distribute the Request action when the component mounts, and then distribute another action in the callback function onSuccess of the successful request, storing the data of the request response in the Redux store.
const [fetchData] = useRequest(actionCreator, {
success: (action) = > {
dispatch(updateTempData(groupName, reducer(dataRef.current, action))),
},
});
useEffect((a)= > {
fetchData(args as any);
}, deps);
Copy the code
2.1.5 Uninstalling Components
When the component uninstalls, useTempData automatically clears it if the Store state already holds data about the successful response to the Request Action. After an API request is initiated, useTempData does not store the data for the successful response to the request to the Redux store if the component has been unloaded.
2.2 useEntity
Based on the design of useTempData, we can encapsulate useEntity for unified processing of data such as Entity. I won’t repeat it here.
3. Automatically generate code
Using code generation tools, we can automatically generate the Request Action Creator and interface definition from the Swagger document. And each time, the code is generated with the latest Swagger JSON from the server. This is useful when an interface changes, where you can update the interface definition with a single line of command and then use TypeScript error prompts to change where it is used in turn.
The code generated by the same Swagger will be put in the same file. To avoid collisions in multi-player collaborations, the generated Request Action Creator and interface definition are sorted alphabetically, and each generated file overwrites the previous one. Therefore, we also put a hard rule in the project: generated files can only be generated automatically, not manually modified.
4. The last
The automated code generation tool saves us a lot of work, and when combined with the useRequest, useTempData, and useEntity we talked about earlier, integrating the API becomes a breeze.