React proposes a simple declarative way to build an application’s view. It’s straightforward to write a hierarchy of stateless components or to change a state synchronously. The whole app works as an integration of two pure functions “state(event)” and “view(state)“. The behavior is testable and predictable. But you can’t make something complex without depending on an external data sources integration: side effects come into the stage. 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 you use external state storage, like Redux, your Component will receive old data. Even if you use a local state for a component, there would be an entirely useless waste of user’s bandwidth (and highly probable — a lot of warnings in a console!)
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.
class User extends React.Component {
render() {
const { user } = this.props;
return (
<div className="user">
<img src={user.avatar_url} />
</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 try to ask it about different persons, you will see a lot of requests, and none of them would be canceled. Let’s address this problem using a high order component implementing the cancellation logic.
Component wrapped with Request Watcher
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.sources = {};
this.bindedFuncs = [loadFunc].reduce((map, func) => {
map[func] = (...args) => {
// eslint-disable-line no-param-reassign
if (this.sources[func]) {
this.sources[func].cancel('concurent run');
}
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);
}
componentWillReceiveProps(nextProps) {
const before = loadFuncArgs(this.props);
const after = loadFuncArgs(nextProps);
if (!isEqual(before, after)) {
this.load(...after);
}
}
componentWillUnmount() {
this.mounted = false;
Object.keys(this.sources)
.map((k) => this.sources[k])
.forEach((source) => {
source.cancel('master component unmounted');
});
}
async load(...args) {
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) {
this.setState({ loading: false, error: null, data }); // eslint-disable-line react/no-did-mount-set-state
}
} catch (err) {
if (this.mounted) {
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} />
</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>
);
}
}
The idea behind this HOC is to create a token and pass it into every function call with network side effects. The request watcher HOC will watch the React lifecycle for you. If Component cease to exist or there are more recent calls to that function, it will cancel the request. As a bonus, it will look if your props are changed to decide whether to run a new query or you’d be fine without it.