Stacking Higher-Order Components

In React, a set of props can flow from one wrapper component to another to another, down a practically limitless chain, receiving modifications from each component. This creates infinite possibilities for combining HOCs. Extracting small pieces of functionality into HOCs, instead of allowing components to grow in complexity, is an important skill for keeping a React codebase manageable.

In this section, you’ll add a new feature to Carousel: the ability to auto-advance the slideIndex on Carousel with a timer. All of the timer logic will be encapsulated in a new HOC.

Working with Timers

Let’s create a new HOC called AutoAdvances. It’ll be designed to work inside of a HasIndex, calling the increment function it receives after a certain time interval. The interval will be specified by a prop with a default value of, say, 10 seconds. The HOC should take two arguments, specifying which prop it should increment and which it should use to compute the upper bound for the increment function. The internal timer will reset every time the target prop (e.g., slideIndex) changes so that the carousel doesn’t auto-advance immediately after the user switched slides using the “Prev” or “Next” buttons.

Start by creating a dummy implementation:

 // src/AutoAdvances.js
 import​ React ​from​ ​'react'​;
 
 export​ ​default​ (Component, propName, upperBoundPropName) =>
 class​ ComponentWithAutoAdvance ​extends​ React.PureComponent {
 static​ displayName = ​`AutoAdvances(​${Component.displayName ||
  Component.name}​)`​;
 
  render() {
 return​ <Component ​{​...this.props​}​ />;
  }
  };

Then formalize the HOC’s requirements as a suite of tests. For the sake of brevity, all of the tests are presented at once below. If you’re trying to practice TDD, you should add one test at a time, then modify the implementation to make that test pass before adding the next one:

 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
 import​ AutoAdvances ​from​ ​'../AutoAdvances'​;
 describe(​'AutoAdvances()'​, () => {
 const​ MockComponent = () => ​null​;
  MockComponent.displayName = ​'MockComponent'​;
 const​ MockComponentWithAutoAdvance = AutoAdvances(
  MockComponent,
 'index'​,
 'upperBound'
  );
 
  it(​'has the expected displayName'​, () => {
  expect(MockComponentWithAutoAdvance.displayName).toBe(
 'AutoAdvances(MockComponent)'
  );
  });
 
const​ autoAdvanceDelay = 10e3;
 const​ upperBound = 5;
 let​ indexIncrement;
 let​ wrapper;
  beforeEach(() => {
  indexIncrement = jest.fn();
jest.useFakeTimers();
  wrapper = shallow(
  <MockComponentWithAutoAdvance
  autoAdvanceDelay=​{​autoAdvanceDelay​}
  index=​{​0​}
  indexIncrement=​{​indexIncrement​}
  upperBound=​{​upperBound​}
  />
  );
  });
 
  it(​'calls the increment function after `autoAdvanceDelay`'​, () => {
jest.advanceTimersByTime(autoAdvanceDelay);
  expect(indexIncrement).toHaveBeenCalledWith(upperBound);
  });
 
  it(​'uses `upperBound.length` if upperBound is an array'​, () => {
  wrapper.setProps({ upperBound: [1, 2, 3] });
  jest.advanceTimersByTime(autoAdvanceDelay);
  expect(indexIncrement).toHaveBeenCalledWith(3);
  });
 
  it(​'does not set a timer if `autoAdvanceDelay` is 0'​, () => {
  wrapper.setProps({ index: 1, autoAdvanceDelay: 0 });
  jest.advanceTimersByTime(999999);
  expect(indexIncrement).not.toHaveBeenCalled();
  });
 
 
 
 
 
 
 
 
 
  it(​'resets the timer when the target prop changes'​, () => {
  jest.advanceTimersByTime(autoAdvanceDelay - 1);
  wrapper.setProps({ index: 1 });
  jest.advanceTimersByTime(1);
  expect(indexIncrement).not.toHaveBeenCalled();
  jest.advanceTimersByTime(autoAdvanceDelay);
  expect(indexIncrement).toHaveBeenCalled();
  });
 
  it(​'clears the timer on unmount'​, () => {
wrapper.unmount();
  jest.advanceTimersByTime(autoAdvanceDelay);
  expect(indexIncrement).not.toHaveBeenCalled();
  });
 });

10e3 is scientific notation for 10 followed by 3 zeros, i.e. 10,000. This is a handy way to express the value “10 seconds” in milliseconds, the standard unit for timers in JavaScript.

Using real timers would make our tests painfully slow, so Jest offers the ability to mock out the native timer functions with jest.useFakeTimers().[67] Calling it before each test ensures that the timer state is reset.

When using fake timers, you need to manually “advance” them with jest.advanceTimersByTime(). For example, a simulated 10s timer will go off after one or more jest.advanceTimersByTime() calls adding up to a total of 10s.

It’s important to ensure that timers are cleaned up when components are unmounted. Enzyme’s unmount() simulates this, invoking the component’s componentWillUnmount() lifecycle method.

Now you have all the tests you need. Before you go on, you might want to brush up on React lifecycle methods.[68] This implementation will make use of componentDidMount(), componentDidUpdate(), and componentWillUnmount():

 import​ React ​from​ ​'react'​;
»import​ PropTypes ​from​ ​'prop-types'​;
 
 export​ ​default​ (Component, propName, upperBoundPropName) =>
 class​ ComponentWithAutoAdvance ​extends​ React.PureComponent {
 static​ displayName = ​`AutoAdvances(​${Component.displayName ||
  Component.name}​)`​;
»static​ propTypes = {
» [propName]: PropTypes.number.isRequired,
» [​`​${propName}​Increment`​]: PropTypes.func.isRequired,
» [upperBoundPropName]: PropTypes.oneOfType([
» PropTypes.number,
» PropTypes.array,
» ]).isRequired,
» autoAdvanceDelay: PropTypes.number.isRequired,
» };
»
»static​ defaultProps = {
» autoAdvanceDelay: 10e3,
» };
»
» componentDidMount() {
»this​.startTimer();
» }
»
» componentDidUpdate(prevProps) {
»if​ (
» prevProps[propName] !== ​this​.props[propName] ||
» prevProps[upperBoundPropName] !== ​this​.props[upperBoundPropName]
» ) {
»this​.startTimer();
» }
» }
»
» componentWillUnmount() {
» clearTimeout(​this​._timer);
» }
»
» startTimer() {
» clearTimeout(​this​._timer);
»if​ (!​this​.props.autoAdvanceDelay) ​return​;
»
»let​ upperBound;
»if​ (​typeof​ ​this​.props[upperBoundPropName] === ​'number'​) {
» upperBound = ​this​.props[upperBoundPropName];
» } ​else​ ​if​ (​this​.props[upperBoundPropName] != ​null​) {
» upperBound = ​this​.props[upperBoundPropName].length;
» }
»
»this​._timer = setTimeout(() => {
»this​.props[​`​${propName}​Increment`​](upperBound);
» }, ​this​.props.autoAdvanceDelay);
» }
 
  render() {
»const​ { autoAdvanceDelay: _autoAdvanceDelay, ...rest } = ​this​.props;
»return​ <Component ​{​...rest​}​ />;
  }
 };

The logic here compares the current value of the target prop to the previous value (that is, the value before the update that triggered componentDidUpdate()) and calls startTimer() if the value has changed. Likewise if the upper bound prop value has changed.

When a component uses timers that should only fire if the component remains mounted, it should clear those timers in componentWillUnmount(). The underscore prefix for the timer handle, this._timeout, is a common convention for locally bound variables on component instances.

Clearing any existing timer at the start of startTimer() ensures that only one timer is ever pending.

Here, upperBound is allowed to be either a numeric value or the length of an array. In the case of Carousel, we know the upper bound will always be computed as slides.length, but allowing a number gives the HOC more flexibility.

Ready for a commit:

 :sparkles: Add AutoAdvances HOC

Testing Multiple Higher-Order Components

Adding the new auto-advance functionality to Carousel should be a simple matter of importing the new AutoAdvances HOC and inserting it between the HasIndex wrapper and the core component:

»export​ ​default​ HasIndex(
» AutoAdvances(Carousel, ​'slideIndex'​, ​'slides'​),
»'slideIndex'
»);

But how do you use tests to confirm that all of these parts are wired together the way you expect? shallow() is ill-suited to this task. This calls for mount():

 describe(​'component with HOC'​, () => {
 let​ mounted;
 
  beforeEach(() => {
  mounted = mount(<Carousel slides=​{​slides​}​ />);
  });
 
  it(​'passes `slides` down to the core component'​, () => {
  expect(mounted.find(CoreCarousel).prop(​'slides'​)).toBe(slides);
  });
 
  it(​'sets slideIndex={0} on the core component'​, () => {
  expect(mounted.find(CoreCarousel).prop(​'slideIndex'​)).toBe(0);
  });
 
  it(​'allows `slideIndex` to be controlled'​, () => {
  mounted = mount(<Carousel slides=​{​slides​}​ slideIndex=​{​1​}​ />);
  expect(mounted.find(CoreCarousel).prop(​'slideIndex'​)).toBe(1);
  mounted.setProps({ slideIndex: 0 });
  expect(mounted.find(CoreCarousel).prop(​'slideIndex'​)).toBe(0);
  });
 
» it(​'advances the slide after `autoAdvanceDelay` elapses'​, () => {
» jest.useFakeTimers();
»const​ autoAdvanceDelay = 10e3;
» mounted = mount(
» <Carousel slides=​{​slides​}​ autoAdvanceDelay=​{​autoAdvanceDelay​}​ />
» );
» jest.advanceTimersByTime(autoAdvanceDelay);
» mounted.update();
» expect(mounted.find(CoreCarousel).prop(​'slideIndex'​)).toBe(1);
» });
 });

One of the biggest “gotchas” in Enzyme’s mount() is that a component’s state changes don’t cause its tree to re-render. So whenever you want to test that the mounted tree changes in response to some event, you’ll need to manually call update().[69]

With those tests green, you can commit with confidence:

 :sparkles: Add auto-advance support to Carousel

That concludes the coding portion of this chapter. You’ve successfully used HOCs for both refactoring and keeping new features in isolation, preventing complexity sprawl. Before the chapter concludes, we’ll take a brief look at the components you’ve built from another angle.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
3.144.123.155