I am currently building some apps using Typescript and React. To this date i have used some smelly workarounds for the following situation which i would like to get rid of. Maybe you know a better way of doing such.
The Setup: Having a React Component which sould display data which is fetched from server on user input. Example use case: fetch the name of a city from an API based on a postal code entered by user. Example implementation:
import * as React from 'react';
export interface IXmplState {
plc: string;
name: string;
}
export default class Xmpl extends React.Component<{}, IXmplState> {
constructor(props){
super(props);
this.state = {name: "", plc: ""};
}
private fetchName(plc: string): Promise<string> {
//Fetch data from server.
}
private updateName(plc: string): void {
this.fetchName(plc).then(newName => this.setState({name: newName}));
}
public render(): React.ReactElement<{}> {
return(
<div>
<input value={this.state.plc} onChange={(event) => this.updateName(event.target.value)}/>
<div>{this.state.name}</div>
</div>
);
}
}
The problem:
As soon as the user input changes, the updateName()
is called, which then updates the state
on resolved promise. Consider the following case:
- The input is changed and a an update promise (A) is created and now pending.
- The input is changed again and a new update promise (B) is created.
- Promise B is resolved while promise A is still pending.
- The state will now be changed to the result of promise B.
- Promise A is now resolved and will override the result of Promise B in the state.
- The user is now presented with the wrong name (schould be result of B but is result of A)
Are there ways to surpress such behavior? Are there specific ways/libs for doing this in React, typescript or javascript? Or is such kind of input handling generally to be avoided? Which would be a better way or the best way of handling such scenario in gernerally?
Greets and Thanks
EDIT: for the sake of completeness.
My current way of handling such scenarios is introducing a checksum in the component and only update the state if the checksum is still not altered.
export default class Xmpl extends React.Component<{}, IXmplState> {
let nameCkSm: number = 0;
...
private updateName(plc: string): void {
let ckSm = this.nameCkSm;
this.fetchName(plc).then(newName => this.setState(() => {
if(this.nameCkSm === ckSm) return {name: newName};
}));
}
CodePudding user response:
AbortController
(standard web feature, not a lib) is good for this, see ***
comments:
export default class Xmpl extends React.Component<{}, IXmplState> {
// *** An AbortController for the update
pendingNameController: AbortController | null = null;
constructor(props: IXmplState) {
super(props);
this.state = { name: "", plc: "" };
}
// *** Accept the signal
private fetchName(plc: string, signal?: AbortSignal): Promise<string> {
// Fetch data from server, pass `signal` if the mechanism supports
// it (`fetch` and `axios` do, for instance)
}
private updateName(plc: string): void {
// *** Cancel any outstanding call
this.pendingNameController?.abort();
// *** Get a controller for this call, and its signal
this.pendingNameController = new AbortController();
const { signal } = this.pendingNameController;
// *** Pass the signal to the `fetchName` method
this.fetchName(plc, signal)
.then((name) => {
// *** Don't update if the request was cancelled (ideally you'd
// never get here because a cancelled request won't fulfill the
// promise, but race conditions can mean you would)
if (!signal.aborted) {
this.setState({ name });
}
})
.catch((error) => {
// ...handle.report error...
})
}
public render(): React.ReactElement<{}> {
// ...
}
}
You can write a utility that handles multiple outstanding requests for different things by wrapping the fetching method, etc. But that's the basic mechanism for using AbortController
for this.
CodePudding user response:
Your biggest enemy here is the network request and the fact that some take longer than others - resulting in race conditions in which things are happening out of order.
The most important thing you need to do is to abort the previous request and let the "latest" one take precedence. This is done with the help of an AbortController.
I know you probably won't like this, but I highly advise you move to functional components and hooks. The reason I say this is because it's much easier to organize your code for the different things which are going on, which will make it easier to prevent the race condition. In particular, the useEffect
hook has a mechanism for cancelling the previous effect... in this case your API request. Here's what it would look like (not tested, but it should be very close):
// This is a hook whose only purpose is to make an http request any time the searchQuery changes.
// It takes care of cancelling the previous request
const useSearchRequest = (searchQuery) => {
const [state, setState] = useState({ isLoading: false, error: null, result: null });
useEffect(() => {
if (searchQuery) {
// update the state to show we are loading
setState(prevState => ({ ...prevState, isLoading: true }));
try {
// create the abort controller and pass the signal to the request
const controller = new AbortController();
fetch(`/search?query=${searchQuery}`, { signal: controller.signal })
.then(req => req.json())
.then(result => {
// update the state with the results
setState({ isLoading: false, error: null, result });
});
} catch(error) {
// update the state with the error
setState(prevState => ({ ...prevState, isLoading: false, error }));
}
// any time searchQuery changes, this method will be called and will cancel the previous request
return () => controller.abort();
}
// this tells the effect to rerun every time searchQuery changes
}, [searchQuery]);
return state;
}
const Xmpl = () => {
const [searchVal, setSearchVal] = useState('');
const { isLoading, error, result } = useSearchRequest(searchVal);
return(
<div>
{isLoading ? 'loading...' : null}
{error ? `There was an error: ${err}` : null}
<input value={searchVal} onChange={(event) => setSearchVal(event.target.value)}/>
<div>{result}</div>
</div>
);
}