Controller pattern
How do you trigger an action in a React component, from within a different React component?
This question would be pretty easy to answer if it were asked of a Backbone app.
const view = new TweetsView({ user: '@ryaninvents' });
$button.on('click', () => view.refreshTweets());
However, in React-land, it's not so obvious.
import Tweets from './Tweets';
function App() {
return (
<div>
<button /* onClick = ??? */>Refresh tweets</button>
<Tweets user="@ryaninvents" />
</div>
);
}
How can we link the button to the Tweets
component, so that clicking the button will reload the tweets?
It's possible to directly call a refresh
method on the Tweets
component directly. That's a bad idea, though; it's not "the React way" of doing things. One core tenet of React development is that your view should be a pure function of your application's state, and if your components can "reach into" each other to change things, you violate this assumption. So how can this be handled?
One possible answer takes inspiration from the long-awaited proposal for aborting a fetch
request. The API for an abortable fetch adds a new class called AbortController
, which you can pass as a parameter to the fetch()
function. For instance, here's an example of how you might abort a fetch if it takes longer than 5 seconds:
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
fetch(url, { signal: controller.signal })
.then(response => response.text())
.then(text => { console.log(text); });
There are three pieces that need to be implemented for you to do this:
- You create and control the
AbortController
. - When you might want to abort something, you pass it the signal from your controller.
- When you decide you do want to abort that request, you call
controller.abort()
.
Let's take a cue from this and try to design a RefreshController
class that covers these three user needs.
Designing RefreshController
To check item (1) off the list, all we need to do is create an empty class:
class RefreshController {
}
Item (2) is a little trickier. We don't actually know what signal
does, but it seems to be some sort of event emitter. We can use EventEmitter
from the Node.js events
built-in module for this; Browserify and Webpack will polyfill this for us in the browser.
import EventEmitter from 'events';
class RefreshController {
constructor() {
this.signal = new EventEmitter();
}
}
Now, for part (3), we just need to define the API. Since we're only going to ask the controller to perform a simple refresh, this is pretty easy:
import EventEmitter from 'events';
class RefreshController {
constructor() {
this.signal = new EventEmitter();
}
refresh() {
this.signal.emit('refresh');
}
}
Redesigning the Tweets
component
Now, we just need to make our Tweets
component understand what it means when we hand it a refresh signal. Let's say for the sake of discussion that the Tweets
component looks like this:
import React from 'react';
import PropTypes from 'prop-types';
import TwitterApi from './TwitterApi';
class Tweets extends React.Component {
static propTypes = {
user: PropTypes.string.isRequired,
};
state = {
tweets: [],
loading: false,
};
refresh = () => {
this.setState({loading: true});
// Assume that `loadTweetsForUser` just returns an array of strings
// representing the most recent tweets.
TwitterApi.loadTweetsForUser(this.props.user)
.then((tweets) => {
this.setState({tweets});
});
}
render() {
if (this.state.loading) {
return <em>Loading, please wait...</em>;
}
return (
<ul>
{this.state.tweets.map((tweet, i) => <li key={i}>{tweet}</li>)}
</ul>
)
}
}
We'll need it to accept a new prop representing the refresh signal:
// ...
static propTypes = {
user: PropTypes.string.isRequired,
refreshSignal: PropTypes.object.isRequired,
};
// ...
And we'll also need it to respect that refresh signal. To make sure it's hooked up correctly, we'll need to use the React lifecycle methods.
// ...
componentDidMount() {
// When component mounts, start listening for the refresh signal.
this.props.refreshSignal.addListener('refresh', this.refresh);
}
componentWillReceiveProps({refreshSignal}) {
// If the refresh signal changes...
if (refreshSignal !== this.props.refreshSignal) {
// ...stop listening to the old one...
this.props.refreshSignal.removeListener('refresh', this.refresh);
// ...and start listening to the new one.
refreshSignal.addListener('refresh', this.refresh);
}
}
componentWillUnmount() {
// Stop listening to the signal when this component is unmounted.
this.props.refreshSignal.removeListener('refresh', this.refresh);
}
// ...
Using the new signal
Now, we're all ready to use the controller in our App
component:
import RefreshController from './RefreshController';
import Tweets from './Tweets';
function App() {
const controller = new RefreshController();
return (
<div>
<button onClick={() => controller.refresh()}>Refresh tweets</button>
<Tweets user="@ryaninvents" refreshSignal={controller.signal} />
</div>
);
}
When the user clicks the button, it emits a "refresh" event from the signal. The Tweets
component, which has been listening to the signal, receives the event and reloads the tweets.
This approach is flexible, since you can attach data to the events, and also emit multiple types of event if your application needs it.
What use cases might you have for this approach?