This post is a quick lesson, by example, of how to refactor a React component to keep business logic out of the view layer.
One component of a product that I’m currently developing is a micro meditation reminder. This component allows the user to set an interval in which they want to do 3 different types of meditation.
Once the interval is set, a textbox shows how long the user has until it’s time to do that meditation. When the timer reaches 0, a browser notification is displayed and the timer resets.
The component is fully functional, but all of the logic about how the timer works is directly embedded in the React Component object. This is not a great design because it makes it hard to test any functionality in isolation. As such I only have one test currently. That test drives the changing of the Interval field in the DOM and asserts that the Next field updates.
We are going to use TDD to refactor this component such that the timer logic no longer lives directly in the component so that we can have a sane and testable interface.
Here is the code and the test we are starting with.
class MeditationListItem extends Component {
constructor(props) {
super(props);
this.state = {interval: 0, nextIn: "not scheduled"}
}
componentDidMount() {
this.timerLoopID = setInterval(this.timerLoop.bind(this), 100);
}
componentWillUnmount() {
clearInterval(this.timerLoopID);
}
intervalUpdated(event) {
const interval = event.target.value;
this.setUpTimerForInterval(interval);
this.calculateNextIn();
}
setUpTimerForInterval(interval) {
const nextAt = moment().add(interval, 'minutes');
this.setState({ interval: interval, nextAt: nextAt });
}
calculateNextIn() {
if (this.state.nextAt) {
this.setState({nextIn: this.state.nextAt.from(moment()) });
}
}
timerExpired() {
showNotification(this.props.name);
this.setUpTimerForInterval(this.state.interval);
}
timerLoop() {
if (this.state.nextAt) {
if (this.state.nextAt < moment()) {
this.timerExpired()
}
else {
this.calculateNextIn();
}
}
}
render() {
return (
<ListItem>
<ListItemText primary={this.props.name} />
<TextField
label="Interval (Min.)"
margin="normal"
type="number"
defaultValue='0'
onChange={this.intervalUpdated.bind(this)}
className="interval-field"
/>
<TextField
label="Next"
value={this.state.nextIn}
margin="normal"
className="next-in"
InputProps={{
readOnly: true,
}}
/>
</ListItem>
)
}
}
export default MeditationListItem
The first thing that jumps out to me when I think about refactoring this is that the stuff we want to remove from the React Component is the timer logic. So, I’m going to create a class called MeditationTimer
and use unit tests to drive the development of its interface.
We know the MeditationTimer
class is going to need to know about the name of the meditation and the interval in which we want it to go off, so lets start with that
class MeditationTimer {
constructor(name, interval) {
this.name = name;
this.interval = interval;
}
}
export default MeditationTimer
And drive that via a test
it('can be instantiated with name and interval', () => {
const meditationTimer = new MeditationTimer('test', 5);
expect(meditationTimer.name).toEqual('test');
expect(meditationTimer.interval).toEqual(5);
});
What else needs to go into this new class?
Well, it’s almost everything else in the React Component except for the markup!
The next thing that I’m going to move is the calculation of nextAt
and nextIn
Those are the key values that makeup the state of the component.
I’m going to do that in a function called timerState()
class MeditationTimer {
constructor(name, interval) {
this.name = name;
this.interval = interval;
this.nextAt = moment().add(this.interval, 'minutes');
}
timerState() {
return {
nextIn: this.nextAt.from(moment()),
nextAt: this.nextAt,
interval: this.interval
};
}
}
describe('timerState()', () => {
let startingMoment;
let meditationTimer;
beforeEach(() => {
startingMoment = moment();
meditationTimer = new MeditationTimer('test', 5);
});
it('sets nextAt on initialization', () => {
expect(meditationTimer.timerState().nextAt.isAfter(startingMoment)).toEqual(true);
});
it('sets interval on initialization', () => {
expect(meditationTimer.timerState().interval).toEqual(5);
});
it('calculates nextIn when called', () => {
expect(meditationTimer.timerState().nextIn).toEqual("in 5 minutes");
});
});
Looks pretty good.
Next is the timer loop itself
I’ll drive that out using tests like so
class MeditationTimer {
constructor(name, interval, callback) {
this.name = name;
this.interval = interval;
this.callback = callback;
this.setNextAt();
this.notify = showNotification;
}
start() {
return this.timerLoopID = setInterval(this.timerLoop.bind(this), 100);
}
stop() {
return clearInterval(this.timerLoopID);
}
setInterval(interval) {
this.interval = interval;
this.setNextAt();
this.callback(this.timerState());
}
timerState() {
return {
nextIn: this.nextAt.from(moment()),
nextAt: this.nextAt,
interval: this.interval
};
}
private
setNextAt() {
this.nextAt = moment().add(this.interval, 'minutes');
}
timerExpired() {
this.notify(this.name);
this.setNextAt();
}
timerLoop() {
if (this.interval > 0) {
if (this.nextAt < moment()) {
this.timerExpired();
}
this.callback(this.timerState());
}
}
}
export default MeditationTimer;
const mockCallback = jest.fn();
beforeEach(() => {
startingMoment = moment();
meditationTimer = new MeditationTimer('test', 5, mockCallback);
});
describe('setInterval', () => {
it('updates interval and calculates nextAt', () => {
const originalNextAt = meditationTimer.timerState().nextAt;
meditationTimer.setInterval(6);
expect(meditationTimer.interval).toEqual(6);
expect(meditationTimer.timerState().nextAt.isAfter(originalNextAt)).toEqual(true);
});
});
describe('timerLoop', () => {
describe('when interval is 0', () => {
it('is a no op', () => {
meditationTimer = new MeditationTimer('test', 0, mockCallback);
meditationTimer.timerExpired = jest.fn();
meditationTimer.callback = jest.fn();
meditationTimer.timerLoop();
expect(meditationTimer.timerExpired).not.toHaveBeenCalled();
expect(meditationTimer.callback).not.toHaveBeenCalled();
});
});
describe('when interval is 1', () => {
it('calls the callback', () => {
meditationTimer = new MeditationTimer('test', 1, mockCallback);
meditationTimer.callback = jest.fn();
meditationTimer.timerLoop();
expect(meditationTimer.callback).toHaveBeenCalled();
})
});
describe('when timer is expired', () => {
it('resets the timer', () => {
meditationTimer = new MeditationTimer('test', 1, mockCallback);
meditationTimer.nextAt = moment().subtract(1, 'day');
meditationTimer.notify = jest.fn();
const originalNextAt = meditationTimer.timerState().nextAt;
meditationTimer.timerLoop();
expect(meditationTimer.timerState().nextAt).not.toEqual(originalNextAt);
})
});
});
describe('start and stop', () => {
it('starts and clears a js interval', () => {
const timerId = meditationTimer.start();
expect(timerId).not.toBeNaN();
meditationTimer.stop();
});
});
Now, we have 100% test coverage.
And our React Component contains nothing expect what a view should!
class MeditationListItem extends Component {
constructor(props) {
super(props);
this.state = {interval: 0, nextIn: "not scheduled"}
this.timer = new MeditationTimer(this.props.name, 0, this.timerStateUpdated.bind(this));
}
componentDidMount() {
this.timer.start();
}
componentWillUnmount() {
this.timer.stop();
}
intervalUpdated(event) {
this.timer.setInterval(event.target.value);
}
timerStateUpdated(timerState) {
this.setState(timerState);
}
render() {
return (
<ListItem>
<ListItemText primary={this.props.name} />
<TextField
label="Interval (Min.)"
margin="normal"
type="number"
defaultValue='0'
onChange={this.intervalUpdated.bind(this)}
className="interval-field"
/>
<TextField
label="Next"
value={this.state.nextIn}
margin="normal"
className="next-in"
InputProps={{
readOnly: true,
}}
/>
</ListItem>
)
}
}
export default MeditationListItem
Top comments (0)