Controller pattern

Originally published Sep 29, 2017·Tagged #web-development, #react, #software-architecture

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:

  1. You create and control the AbortController.
  2. When you might want to abort something, you pass it the signal from your controller.
  3. 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?