Refactoring React

Refactoring React

Josh McCormick

August 21, 2017

One of my favorite front-end tools these days is React, and those who've talked with me about front-end work know I'm vocal about it. I happily recommend it, I've worked with it for years, and I spend a lot of time building things with it just for fun (or out of curiosity).

One question I do get a lot as a result of being so vocal is this: "How should I best test my React components?" Which, yeah, if I'm being honest, I get a lot of the confusion and frustration.

Why is it such a confusing topic? I'm guessing that there are a few things going on here.

For one, front-end work is mainly meant for the DOM, so a lot of the time I believe we internalize this notion that front-end code has be tested through its rendered, or DOM state.

To add more complexity to that, React itself is an engine that blends state, presentation, and lifecycle functionality together. It does such a good job of this, that it appears intuitive and seamless. Even so, these are truly distinct concepts. More so if you're writing tests.

Or, in other words, we've been told to look at a the idea of testing a React component—a thing which is an integration point, merging together several layers of concepts—and see that component as a single unit.

A counter app

Check out this super-standard Counter component:

// ./Counter.js
//
import React, { Component } from 'react';

export default class Counter extends Component {
		constructor(props) {
				super(props);

				this.state = { tally: 0 };
		}

		render() {
				const { tally } = this.state;
				const add = () => this.setState({ tally: tally + 1 });
				const subtract = () => this.setState({ tally: tally - 1 });

				return(
						<div className="counter-container">
								<button className="counter-increment" onClick={ add }>
										+
								</button>
								<div className="counter-tally">
										{ tally }
								</div>
								<button className="counter-decrement" onClick={ subtract }>
										-
								</button>
						</div>
				);
		}
}

Pretty simple stuff, right? Well, no. It looks simple because React is giving us a great abstraction kit to handle common view layer problems. But there's a ton of stuff going on here:

  • We take props and yield them to our superclass. There's a change detection algorithm React uses here to create cache hits / misses on DOM renders. Meaning that with the super(props) statement, we're seeding how that algorithm proves / disproves our component cache.
  • We're initializing a custom state, but we're using an internally created object to represent it. That means this component owns that state and encapsulates it—the side effect here is that we have to actually do extra work to reach into the component to prove or disprove this state.
  • We author several callback handlers that mutate that state, and we bind those directly to React elements generated in render. So again, in order to test these we'd have to directly access the internals of the component.

Because this is such a straightforward component, it's not that tough to write tests right now. We can probably just render it with Enzyme inside of a vanilla mocha / chai setup.

(Jest and Karma are great, but why worry about them now? They add complexity we don't need up front. Let's save that stuff for some other time.)

// ./Counter.spec.js
//
import React from 'react';
import { shallow } from 'enzyme';
import chai, { expect } from 'chai';
import chaiEnzyme from 'chai-enzyme';
import Counter from './Counter';

chai.use(chaiEnzyme());

describe('<Counter />', () => {
		it('has two buttons', () => {
				const component = shallow(<Counter />);
				expect(component.find('button')).to.have.length(2);
		});

		it('shows the current tally', () => {
				const component = shallow(<Counter />);
				expect(component.find('.counter-tally')).to.have.text('0');
		});

		it('increments the state', () => {
				const component = shallow(<Counter />);
				component.find('button.counter-increment').simulate('click');
				expect(component.find('.counter-tally')).to.have.text('1');
		});

		it('decrements the state', () => {
				const component = shallow(<Counter />);
				component.find('button.counter-decrement').simulate('click');
				expect(component.find('.counter-tally')).to.have.text('-1');
		});
});

However, if we take a step back, those sorts of test assertions are already very complex. In a lot of cases, we need a thorough understanding of React in order to perform them really effectively.

So what happens if the example isn't so simple? What about when a button press is an asynchronous operation, and we need to report fetching state, or failure/success states? What happens if our UI needs to change dramatically?

What happens when our testing has to go just a little bit past simple presentational / state coupling, and go deep into the realm of branching logic and asynchrony?

Splitting things up

Let's think for a moment about what's happening. We're actually reaching into React, and we're asserting that these internally managed things are true. This is a whole lot like end-to-end testing, isn't it?

Have you seen Sandi Metz's talk The Magic Tricks of Testing? It was a huge eye-opener for me, and I go back to it all of the time. One of the big takeaways I got from it was that we, as software crafters, ought to be testing our own logic, not necessarily whether or not a framework is functioning as advertised.

In the above case we're doing a lot of framework testing, but what do we really care about here?

We care about:

  • The shape of our state ({ tally: <number> }). Maybe?
  • Incrementing our state, and decrementing it.
  • We care that one button links to an increment functionality, and the other a decrement functionality.
  • We care that we display the tally value.

The truth is that none of this is really related to lifecycles, and only some of it is linked to views. Of course it's a little frustrating to test those assertions against React. In several of these cases, React doesn't have a thing to do with what we want to prove.

The Elm Architecture

Have you ever played with Elm? It's another great tool. We don't want to veer too hard to Elm here, except to observe something cool about the Elm ecosystem: the way Elm programs tend to structure their mostly UI-centric code.

There's a pattern that emerges time and time again in Elm programs, known as the Elm Architecture. In a nutshell, it says that you can achieve greater modularity and ease of design by splitting your UI (or, if I'm being honest, tons of different structures / systems) up into three major functions:

  • View - This is a function, you feed it your model and it returns UI.
  • Model - This is your application / component state.
  • Update - Another function, this one you feed a message and a model to, and it returns a changed version of the model.

If you're familiar with the React community, then you may already know React-specific implementations of this concept. The most notable is Redux, which is an incredible tool.

However, we don't need to install Redux to use some of the Elm Architecture lessons. We can use plain JS for that. And so that's our mission: we want to get tiny bits tested and then plug them in to React.

Step 1: Actions and State

Let's start by thinking of our state as something that exists on its own, which we plug into a component. We want two state-related functions, init and update.

// ./state.js
export const init = () => ({ tally: 0 });
export const update = (action, state) => { ...state };

// ./state.spec.js
import { expect } from 'chai';
import * as State from './state';

describe('Counter state', () => {
		describe('#init', () => {
				it('holds an initial tally of 0', () => {
						expect(State.init()).to.eql({ tally: 0 });
				});
		});

		describe('#update', () => {
				it('returns the given state, by default', () => {
						const state = State.init();
						expect(State.update('NONSENSE_ACTION', state)).to.eql(state);
				});
		});
});

It doesn't really do much yet, does it? For real functionality, we need to add in some actions. These will be, in our case, constant strings, which we'll declare in their own file.

// ./actions.js
//
export const INCREMENT = "INCREMENT";
export const DECREMENT = "DECREMENT";

That's actually it for this file. Let's use these actions to expand our state code:

// ./state.js
//
import { INCREMENT, DECREMENT } from './actions';

export const init = () => ({ tally: 0 });

export const update = (action, state) => {
		switch (action) {
				case INCREMENT:
						return { ...state, tally: state.tally + 1 };
				case DECREMENT:
						return { ...state, tally: state.tally - 1 };
				default:
						return { ...state };
		}
};

// ./state.spec.js
//
import { expect } from 'chai';
import * as State from './state';
import { INCREMENT, DECREMENT } from './actions';

describe('Counter state', () => {
		describe('#init', () => {
				it('holds an initial tally of 0', () => {
						expect(State.init()).to.eql({ tally: 0 });
				});
		});

		describe('#update', () => {
				const state = State.init();

				it('increments state', () => {
						expect(State.update(INCREMENT, state)).to.eql({ tally: 1 });
				});

				it('decrements state', () => {
						expect(State.update(DECREMENT, state)).to.eql({ tally: -1 });
				});

				it('returns the given state, by default', () => {
						expect(State.update('NONSENSE_ACTION', state)).to.eql(state);
				});
		});
});

Easy!

So, here's the thing. If you remember earlier, we were reaching through our React Counter in order to access this state behavior. That's now completely unnecessary. We've tested it—it's sound. Let's see what an intermediary use of this state looks like:

// ./Counter.js
//
import React, { Component } from 'react';
import { INCREMENT, DECREMENT } from './actions';
import * as State from './state';

export default class Counter extends Component {
		constructor(props) {
				super(props);

				this.state = State.init();
		}

		render() {
				const { tally } = this.state;
				const increment = () => this.setState(State.update(INCREMENT, this.state));
				const decrement = () => this.setState(State.update(DECREMENT, this.state));

				return(
						<div className="counter-container">
								<button className="counter-increment" onClick={ increment }>+</button>
								<div className="counter-tally">
										{ tally }
								</div>
								<button className="counter-decrement" onClick={ decrement }>-</button>
						</div>
				);
		}
}

We've done two things here:

  • We're relying on init to initialize our state shape. This means we don't need to worry about that code here except to display it.
  • We rely on update and our INCREMENT and DECREMENT constants for our functionality. All of our state shape changes can be contained in our state file. At the same time, our event callbacks get dramatically simpler; we're just passing our state and a message into another file and updating our component to whatever that file returns.

To add to all of this, if we run our original integration-y tests, they'll still pass. They don't care that we split responsibility of this stuff out into another file; they're just integration tests!

Step 2: Lifecycle and View

The last thing we need to do to really get our component into more discrete testable units is to split off the top-level component (the lifecycle component, or container component) from the view itself.

Let's start by moving our JSX into another file:

// ./view.js
//
import React from 'react';
import { INCREMENT, DECREMENT } from './actions';

const View = ({ onAction, tally }) =>
		<div className="counter-container">
				<button
						className="counter-increment"
						onClick={onAction(INCREMENT)}>
						+
				</button>
				<div className="counter-tally">
						{tally}
				</div>
				<button
						className="counter-decrement"
						onClick={onAction(DECREMENT)}>
						-
				</button>
		</div>;

export default View;

Here we go—a functional (vs. lifecycle) component, focused only on delivering a view that calls given props in an entirely deterministic way.

For our tests, we're going to embrace this determinism. We don't really want to evaluate that the button clicks change state (they don't inherently do that anymore), so our tests are instead going to evaluate that INCREMENT and DECREMENT are being passed into the onAction prop. Other than that, the tests will be very similar to our original integration tests.


// ./view.spec.js
//
import React from 'react';
import { shallow } from 'enzyme';
import chai, { expect } from 'chai';
import chaiEnzyme from 'chai-enzyme';
import { INCREMENT, DECREMENT } from './actions';
import View from './view';

chai.use(chaiEnzyme());

describe('View', () => {
		it('has two buttons', () => {
				const component = shallow(<View tally={0} onAction={() => {}}/>);
				expect(component.find('button')).to.have.length(2);
		});

		it('shows the current tally', () => {
				const component = shallow(<View tally={0} onAction={() => {}}/>);
				expect(component.find('.counter-tally')).to.have.text('0');
		});

		it('increments the state', () => {
				let calledWith = [];

				const onAction = message => () => calledWith.push(message);
				const component = shallow(<View tally={0} onAction={onAction}/>);

				component.find('button.counter-increment').simulate('click');

				expect(calledWith).to.contain(INCREMENT);
		});

		it('decrements the state', () => {
				let calledWith = [];

				const onAction = message => () => calledWith.push(message);
				const component = shallow(<View tally={0} onAction={onAction}/>);

				component.find('button.counter-decrement').simulate('click');

				expect(calledWith).to.contain(DECREMENT);
		});
});

Notice how we're passing in a custom, test-specific onAction function here?

This sort of versatility is one of the rewards of the decoupling. This function is now an explicit seam in our code; one that we can employ to introduce all sorts of extra functionality.

Testing is just the surface perk, but we could use the seam to add side-effects or other sorts of intermediate features, like logging / debugging hooks, and so on.

One other notable difference here is that we don't want to have View conjure up its own state or props, so when we initialize it, we pass those in now. The View we've made is a pure representation of its props, and so it is now truly open for extension.

Let's do one final revision to the Counter component; it needs to use the View component:

// ./Counter.js
//
import React, { Component } from 'react';
import { INCREMENT, DECREMENT } from './actions';
import * as State from './state';
import View from './view';

export default class Counter extends Component {
		constructor(props) {
				super(props);

				this.state = State.init();
		}

		render() {
				const { tally } = this.state;
				const onAction = message => 
						() => 
						this.setState(State.update(message, this.state));

				return <View tally={ tally } onAction={ onAction } />;
		}
}

There we go. Of course, this update will break our tests, because Enzyme only renders one component deep. Let's just update the test to make sure it renders a View:

// ./Counter.spec.js
import React from 'react';
import { shallow } from 'enzyme';
import chai, { expect } from 'chai';
import chaiEnzyme from 'chai-enzyme';
import Counter from './index';

chai.use(chaiEnzyme());

describe('<Counter />', () => {
		it('renders a view', () => {
				const component = shallow(<Counter />);
				expect(component.find('View')).to.have.length(1);
		});
});

That gets us back to green on the tests. It also liberates our state from our component, and ensures we have a concrete locality for each of our specific features (instead of one single file integrating all of them).

Wrapping it up

The topic of decomposition in React is a strangely undiscussed one. This exercise isn't meant to show us the correct way of doing it—I don't know if I could say I know what that is. Mostly, this is an interesting exploration into how to shake off some of the baked-in concepts we might learn in the React tutorial sphere or beyond.

Finally, we're doing a bit of trusting in our tests now. You'll notice that we aren't testing that the View changes every time we click a button anymore. Why is that?

The truth is that we're relying on our framework and tools here. We know that React uses a prop/state comparison system to determine whether or not it should redraw components, and we know that we have a state update in play, and that we are sending it the right messages.

Sure, it's a bit of a trust-fall, but it's an informed and well-tested one. Most importantly, using decoupling patterns like this one, the complexity involved in measuring these trust falls is recognizably reduced.

Sometimes the resistance in our testing isn't so much the tools we're using, but how we've been taught to use them.