React gives you a declarative way to build a view from state. Network requests bring side effects into the picture: their results arrive on their own schedule. Beware, you’re approaching the Dark Zone of functional programming now :)
A network is the most common side effect in any web application. If a component fetches data from a remote location, you can’t predict the time it would take the particular request to finish, and the order of your queries execution, or even the success status of the job. For example, let’s assume the following situation, which is not a rare thing on a slow connection:
- A Component mounts and makes a request A with A1 arguments to fetch the data from a server
- User goes to another page or tab, so the Component unmounts
- User goes back to the first page and the Component mounts again and makes a request B with B1 arguments to fetch the data
- Request B was very lucky, and it completes first
- Request A completes only after request B
If both requests write to the same Redux state without checking which one is current, A can overwrite B. With local component state, the old instance does not update the new one, but its request can still waste bandwidth. A similar race happens without unmounting: ask for Alice, then Bob, and Alice’s late response can replace Bob’s data in the same component.
Simple Component with an external data source
Let’s build a simple component to fetch an avatar URL from github.com for a given person. And let’s make it as simple as it’s possible while still keeping in mind all the loading and error handling stuff.
import React from 'react';
import axios from 'axios';
class User extends React.Component {
render() {
const { user } = this.props;
return (
<div className="user">
<img src={user.avatar_url} alt={`${user.login}'s avatar`} />
</div>
);
}
}
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
user: null,
loading: false,
error: null,
};
}
saveInput = (input) => {
this.input = input;
};
ask = () => {
this.setState((state) => ({ ...state, loading: true, error: null }));
axios({
method: 'get',
url: `https://api.github.com/users/${this.input.value}`,
})
.then(({ data }) => {
this.setState((state) => ({
...state,
loading: false,
user: data,
error: null,
}));
})
.catch((err) => {
this.setState((state) => ({
...state,
loading: false,
user: null,
error: err.message,
}));
});
};
render() {
const { user, loading, error } = this.state;
return (
<React.Fragment>
<div>
<h2>Ask GITHUB about someone:</h2>
<input ref={this.saveInput} defaultValue="astartsky" />
<button onClick={this.ask}>Ask</button>
</div>
{loading && <div>Loading...</div>}
{error && <div>Sorry! {error}</div>}
{!!user && <User user={user} />}
</React.Fragment>
);
}
}Now, if you limit the network speed and ask about different people, none of the requests are canceled, and the last response wins even if it belongs to an older search. Let’s address this with a higher-order component (HOC) that cancels superseded requests and checks which request owns the state update.
Component wrapped with Request Watcher
The examples are separate modules in a React project with JSX and class-field support. Both use React and Axios; the HOC also imports Lodash’s isEqual to compare the argument arrays returned by loadFuncArgs.
This version keeps the original CancelToken interface for existing class-component code. Axios deprecates that API and recommends AbortController for new code. For function components, React describes cleanup that aborts a fetch or ignores its result. The same rule applies here: an obsolete request must not update the current view.
import React from 'react';
import axios from 'axios';
import isEqual from 'lodash/isEqual.js';
const RequestWatcher =
(config = {}) =>
(BaseComponent) => {
const {
// function will be called automatically on component mount
loadFunc = 'load',
// `props => args` function allow to specify `loadFunc` arguments
loadFuncArgs = () => [],
// `props => <Loader />` renderer
renderLoader = () => <div>Loading...</div>,
// `props => message => <Error />` renderer
renderError = () => (message) => <div>Sorry! {message}</div>,
} = config;
return class extends React.Component {
static displayName = `RequestWatcher(${BaseComponent.displayName})`;
constructor(props, context) {
super(props, context);
this.state = {
loading: true,
error: null,
};
this.requestId = 0;
this.sources = {};
this.bindedFuncs = [loadFunc].reduce((map, func) => {
map[func] = (...args) => {
// eslint-disable-line no-param-reassign
if (this.sources[func]) {
this.sources[func].cancel('superseded request');
}
this.sources[func] = axios.CancelToken.source();
return this.props[func](this.sources[func].token, ...args);
};
return map;
}, {});
}
componentDidMount() {
this.mounted = true;
const args = loadFuncArgs(this.props);
this.load(...args);
}
componentDidUpdate(prevProps) {
const before = loadFuncArgs(prevProps);
const after = loadFuncArgs(this.props);
if (!isEqual(before, after)) {
this.load(...after);
}
}
componentWillUnmount() {
this.mounted = false;
this.requestId += 1;
Object.keys(this.sources)
.map((k) => this.sources[k])
.forEach((source) => {
source.cancel('master component unmounted');
});
}
async load(...args) {
if (!this.mounted) return;
const requestId = ++this.requestId;
if (!loadFunc) {
if (this.mounted) {
this.setState({ loading: false, error: null }); // eslint-disable-line react/no-did-mount-set-state
}
return;
}
try {
if (this.mounted) {
this.setState({ loading: true, error: null }); // eslint-disable-line react/no-did-mount-set-state
}
const data = await this.bindedFuncs[loadFunc](...args);
if (this.mounted && requestId === this.requestId) {
this.setState({ loading: false, error: null, data }); // eslint-disable-line react/no-did-mount-set-state
}
} catch (err) {
if (axios.isCancel(err)) return;
if (this.mounted && requestId === this.requestId) {
this.setState({ loading: false, error: err.message }); // eslint-disable-line react/no-did-mount-set-state
}
}
}
render() {
const { data, error, loading } = this.state;
if (!!loadFunc && error) {
return renderError(this.props)(error);
}
if (!!loadFunc && loading) {
return renderLoader(this.props);
}
return (
<BaseComponent
data={data}
isLoading={loading}
error={error}
{...this.props}
{...this.bindedFuncs}
/>
);
}
};
};
class User extends React.Component {
render() {
const { data } = this.props;
return (
<div className="user">
<img src={data.avatar_url} alt={`${data.login}'s avatar`} />
</div>
);
}
}
const UserBinded = RequestWatcher({
loadFuncArgs: ({ name }) => [name],
})(User);
const load = (token, value) =>
axios({
method: 'get',
url: `https://api.github.com/users/${value}`,
cancelToken: token,
}).then(({ data }) => data);
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
user: null,
name: null,
};
}
saveInput = (input) => {
this.input = input;
};
ask = () => {
this.setState((state) => ({ ...state, name: this.input.value }));
};
render() {
const { user, name } = this.state;
return (
<React.Fragment>
<div>
<h2>Ask GITHUB about someone:</h2>
<input ref={this.saveInput} defaultValue="astartsky" />
<button onClick={this.ask}>Ask</button>
</div>
{!!name && <UserBinded name={name} load={load} />}
</React.Fragment>
);
}
}Each load gets a request ID. When the arguments change, the HOC starts a new load and cancels the previous request. Unmounting cancels pending work and invalidates its ID. A result or an error can update state only while the component is mounted and its request ID is still current.
Cancellation rejects the old Axios promise, so axios.isCancel keeps that rejection out of the error UI. Without this check, canceling Alice’s request could display an error and clear the loading indicator while Bob’s request is still pending. The ID check also rejects late results from a loader that ignores cancellation.
The loader must return data without writing to a shared store itself. If it dispatches to Redux before returning, put the request-identity check at that state update too; the HOC cannot undo a dispatch.
To check the behavior, start Alice’s request, then Bob’s. Hold Bob’s response and cancel Alice’s request: the loader should stay visible without an error. Let Bob finish, then deliver a late success or failure from Alice: Bob’s view should stay unchanged. A failure from Bob should still show an error, and neither request should update state after unmounting.






