Making Higher-Order Components

In the abstract, a higher-order component is defined as any function that takes a component and returns another component. Typically, the component returned by the HOC function is a wrapper around the original component that adds functionality. Here’s a simple example:

const​ BindProps = (Component, boundProps) => {
 const​ ComponentWithBoundProps = props => (
<Component ​{​...props​}​ ​{​...boundProps​}​ />
  );
  ComponentWithBoundProps.displayName =
`BindProps(​${Component.displayName || Component.name}​)`​;
 return​ ComponentWithBoundProps;
 };
 
const​ CarouselWithTestAttr = BindProps(Carousel, {
 'data-test-id'​: ​'carousel'​,
 });

The BindProps HOC takes two arguments, a component and a props object to “bind” to that component.

Since the boundProps passed to the HOC are spread into the component after the props passed directly, the boundProps take precedence.

displayName is a special property that gives React components a name for debugging purposes. We haven’t had to deal with it so far because JavaScript functions and classes have a name property that usually works as a fallback. But for a dynamically generated component like this one, you’ll need to set the name manually. Using a name of the form "HOC(Component)" is a common convention.

Here BindProps is used to generate a component that behaves exactly like Carousel, except that it will always receive data-test-id="carousel".

In the next section, you’ll build an HOC to manage an index, like the slideIndex in Carousel. Then you’ll refactor Carousel to make it a stateless component using that HOC.

Creating a Higher-Order Component

Open the test-driven-carousel project from the previous chapter and take a look at the Carousel component. In addition to rendering a somewhat complex DOM tree, it also has one piece of state and two event handlers that manipulate that state. Let’s try building an HOC that encapsulates that logic.

Well-implemented HOCs tend to be highly reusable, and this one will be no exception. It’ll manage the state for any component that has an “index” prop, meaning a number that can go from 0 up to some limit. Call it HasIndex. Start with a minimal dummy implementation that you can run tests against:

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

To replace the slideIndex logic in Carousel, we need HasIndex to provide three props to the wrapped component: the index itself (with the given indexPropName), an increment function, and a decrement function. To support wrap-around, the increment and decrement functions should accept an upper bound argument. Write a test suite with those requirements:

 // src/tests/HasIndex.test.js
 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
 import​ HasIndex ​from​ ​'../HasIndex'​;
 
 describe(​'HasIndex()'​, () => {
 const​ MockComponent = () => ​null​;
  MockComponent.displayName = ​'MockComponent'​;
 const​ MockComponentWithIndex = HasIndex(MockComponent, ​'index'​);
 
  it(​'has the expected displayName'​, () => {
  expect(MockComponentWithIndex.displayName).toBe(
 'HasIndex(MockComponent)'
  );
  });
 
 let​ wrapper;
  beforeEach(() => {
  wrapper = shallow(<MockComponentWithIndex />);
  });
 
  it(​'has an initial `index` state of 0'​, () => {
  expect(wrapper.state(​'index'​)).toBe(0);
  });
 
  it(​'passes the `index` state down as the `index` prop'​, () => {
  expect(wrapper.prop(​'index'​)).toBe(0);
  wrapper.setState({ index: 1 });
  expect(wrapper.prop(​'index'​)).toBe(1);
  });
  it(​'has an `index` state of 2 on decrement from 3'​, () => {
  wrapper.setState({ index: 3 });
  wrapper.prop(​'indexDecrement'​)();
  expect(wrapper.state(​'index'​)).toBe(2);
  });
 
  it(​'has an `index` state of 1 on increment'​, () => {
  wrapper.prop(​'indexIncrement'​)();
  expect(wrapper.state(​'index'​)).toBe(1);
  });
 
  it(​'has the max `index` state on decrement from 0'​, () => {
  wrapper.setState({ index: 0 });
  wrapper.prop(​'indexDecrement'​)(3);
  expect(wrapper.state(​'index'​)).toBe(2);
  });
 
  it(​'has the min `index` state on increment from the max'​, () => {
  wrapper.setState({ index: 2 });
  wrapper.prop(​'indexIncrement'​)(3);
  expect(wrapper.state(​'index'​)).toBe(0);
  });
 });

Then try modifying the implementation to meet those requirements. You should end up with something like this:

 // src/HasIndex.js
 import​ React ​from​ ​'react'​;
 
 export​ ​default​ (Component, indexPropName) =>
»class​ ComponentWithIndex ​extends​ React.PureComponent {
»static​ displayName = ​`HasIndex(​${Component.displayName ||
» Component.name}​)`​;
»
» state = {
» index: 0,
» };
»
» handleDecrement = upperBound => {
»this​.setState(({ index }) => {
»const​ newIndex = upperBound
» ? (index + upperBound - 1) % upperBound
» : index - 1;
»return​ {
» index: newIndex,
» };
» });
» };
»
 
 
 
 
 
 
» handleIncrement = upperBound => {
»this​.setState(({ index }) => {
»const​ newIndex = upperBound ? (index + 1) % upperBound : index + 1;
»return​ {
» index: newIndex,
» };
» });
» };
»
» render() {
»const​ indexProps = {
» [indexPropName]: ​this​.state.index,
» [​`​${indexPropName}​Decrement`​]: ​this​.handleDecrement,
» [​`​${indexPropName}​Increment`​]: ​this​.handleIncrement,
» };
»return​ <Component ​{​...this.props​}​ ​{​...indexProps​}​ />;
» }
» };

Now that all of HasIndex’s tests are green, this would be a good time to commit your work:

 :sparkles: Add HasIndex HOC

Refactoring with Higher-Order Components

With HasIndex, you can now make Carousel a stateless component. Instead of having an initialState and performing setState operations, Carousel will receive slideIndex as a prop and use the slideIndexDecrement and slideIndexIncrement props to update that value.

This change will make the “core” of Carousel simpler (especially as you add new features related to the slide index), but it complicates the shallow testing story. Up to now, Carousel has been a single component that contains all of its own logic. Now it will, in effect, be two components. You’re going to need three types of test to achieve full coverage:

  1. Tests for the component generated by HasIndex (which you already have)
  2. Tests for the core Carousel component
  3. Tests to ensure that the core and the HOC are properly wired together

Start by modifying Carousel.js so that it exports both the core Carousel component and, as the default export, a HasIndex-wrapped version:

 // src/Carousel.js
 import​ React ​from​ ​'react'​;
 import​ PropTypes ​from​ ​'prop-types'​;
 import​ CarouselButton ​from​ ​'./CarouselButton'​;
 import​ CarouselSlide ​from​ ​'./CarouselSlide'​;
»import​ HasIndex ​from​ ​'./HasIndex'​;
 
»export​ ​class​ Carousel ​extends​ React.PureComponent {
  ...
 }
 
»export​ ​default​ HasIndex(Carousel, ​'slideIndex'​);

In Carousel.test.js, you’ll need to import and test both components. Since the component wrapped with HasIndex is the one that your library’s users will consume, import it as Carousel, and import the unmodified component as CoreCarousel:

 // src/tests/Carousel.test.js
 import​ React ​from​ ​'react'​;
 import​ { shallow } ​from​ ​'enzyme'​;
»import​ Carousel, { Carousel ​as​ CoreCarousel } ​from​ ​'../Carousel'​;
 import​ CarouselButton ​from​ ​'../CarouselButton'​;
 import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
 
 describe(​'Carousel'​, () => {
  ...
 
» describe(​'component with HOC'​, () => {
»// Tests against Carousel will go here
» });
»
» describe(​'core component'​, () => {
»// Tests against CoreCarousel will go here
» });
 });

The tests in the describe(’component with HOC’, ...) block only need to verify that the core component is correctly wrapped by HasIndex, since you already have solid test coverage for HasIndex itself. An effective way to confirm that is to check that the core component receives both the props generated by the wrapper component and the props passed to the wrapped component:

 // src/tests/Carousel.test.js
 ...
 describe(​'component with HOC'​, () => {
»let​ wrapper;
»
» beforeEach(() => {
» wrapper = shallow(<Carousel slides=​{​slides​}​ />);
» });
»
» it(​'sets slideIndex={0} on the core component'​, () => {
» expect(wrapper.find(CoreCarousel).prop(​'slideIndex'​)).toBe(0);
» });
»
» it(​'passes `slides` down to the core component'​, () => {
» expect(wrapper.find(CoreCarousel).prop(​'slides'​)).toBe(slides);
» });
 });
 ...

The describe(’core component’, ...) block should be essentially the same as the old Carousel tests, except the tests need to deal with slideIndex as a prop instead of state:

 // src/tests/Carousel.test.js
 ...
 describe(​'core component'​, () => {
»const​ slideIndexDecrement = jest.fn();
»const​ slideIndexIncrement = jest.fn();
»let​ wrapper;
»
» beforeEach(() => {
» wrapper = shallow(
» <CoreCarousel
» slides=​{​slides​}
» slideIndex=​{​0​}
» slideIndexDecrement=​{​slideIndexDecrement​}
» slideIndexIncrement=​{​slideIndexIncrement​}
» />
» );
» });
 
  it(​'renders a <div>'​, () => {
  expect(wrapper.type()).toBe(​'div'​);
  });
 
  it(​'renders a CarouselButton labeled "Prev"'​, () => {
  expect(
  wrapper
  .find(CarouselButton)
  .at(0)
  .prop(​'children'​)
  ).toBe(​'Prev'​);
  });
 
  it(​'renders a CarouselButton labeled "Next"'​, () => {
  expect(
  wrapper
  .find(CarouselButton)
  .at(1)
  .prop(​'children'​)
  ).toBe(​'Next'​);
  });
 
» it(​'renders the current slide as a CarouselSlide'​, () => {
»let​ slideProps;
» slideProps = wrapper.find(CarouselSlide).props();
» expect(slideProps).toEqual({
» ...CarouselSlide.defaultProps,
» ...slides[0],
» });
» wrapper.setProps({ slideIndex: 1 });
» slideProps = wrapper.find(CarouselSlide).props();
» expect(slideProps).toEqual({
» ...CarouselSlide.defaultProps,
» ...slides[1],
» });
» });
»
» it(​'decrements `slideIndex` when Prev is clicked'​, () => {
» wrapper.find(​'[data-action="prev"]'​).simulate(​'click'​);
» expect(slideIndexDecrement).toHaveBeenCalledWith(slides.length);
» });
»
» it(​'increments `slideIndex` when Next is clicked'​, () => {
» wrapper.find(​'[data-action="next"]'​).simulate(​'click'​);
» expect(slideIndexIncrement).toHaveBeenCalledWith(slides.length);
» });
 
  it(​'passes defaultImg and defaultImgHeight to the CarouselSlide'​, () => {
 const​ defaultImg = () => ​'test'​;
 const​ defaultImgHeight = 1234;
  wrapper.setProps({ defaultImg, defaultImgHeight });
  expect(wrapper.find(CarouselSlide).prop(​'Img'​)).toBe(defaultImg);
  expect(wrapper.find(CarouselSlide).prop(​'imgHeight'​)).toBe(
  defaultImgHeight
  );
  });
 
  it(​'allows individual slides to override Img and imgHeight'​, () => {
 const​ Img = () => ​'test'​;
 const​ imgHeight = 1234;
  wrapper.setProps({ slides: [{ ...slides[0], Img, imgHeight }] });
  expect(wrapper.find(CarouselSlide).prop(​'Img'​)).toBe(Img);
  expect(wrapper.find(CarouselSlide).prop(​'imgHeight'​)).toBe(imgHeight);
  });
 });
 ...

jest.fn() creates a mock function, a function that keeps track of calls to itself.[64]

expect(mockFunction).toHaveBeenCalledWith() is an assertion that verifies that mockFunction was called with the given arguments.

With the tests updated, you’re ready to implement the refactoring:

 // src/Carousel.js
 import​ React ​from​ ​'react'​;
 import​ PropTypes ​from​ ​'prop-types'​;
 import​ CarouselButton ​from​ ​'./CarouselButton'​;
 import​ CarouselSlide ​from​ ​'./CarouselSlide'​;
 import​ HasIndex ​from​ ​'./HasIndex'​;
 
 export​ ​class​ Carousel ​extends​ React.PureComponent {
 static​ propTypes = {
  defaultImg: CarouselSlide.propTypes.Img,
  defaultImgHeight: CarouselSlide.propTypes.imgHeight,
» slideIndex: PropTypes.number.isRequired,
» slideIndexDecrement: PropTypes.func.isRequired,
» slideIndexIncrement: PropTypes.func.isRequired,
  slides: PropTypes.arrayOf(PropTypes.shape(CarouselSlide.propTypes))
  .isRequired,
  };
 
 static​ defaultProps = {
  defaultImg: CarouselSlide.defaultProps.Img,
  defaultImgHeight: CarouselSlide.defaultProps.imgHeight,
  };
 
  handlePrevClick = () => {
»const​ { slideIndexDecrement, slides } = ​this​.props;
» slideIndexDecrement(slides.length);
  };
 
  handleNextClick = () => {
»const​ { slideIndexIncrement, slides } = ​this​.props;
» slideIndexIncrement(slides.length);
  };
 
  render() {
 const​ {
  defaultImg,
  defaultImgHeight,
» slideIndex,
» slideIndexDecrement: _slideIndexDecrement,
» slideIndexIncrement: _slideIndexIncrement,
  slides,
  ...rest
  } = ​this​.props;
 return​ (
  <div ​{​...rest​}​>
  <CarouselSlide
  Img=​{​defaultImg​}
  imgHeight=​{​defaultImgHeight​}
 {​...slides​[​slideIndex​]}
  />
 
 
  <CarouselButton data​-​action=​"prev"​ onClick=​{​​this​.handlePrevClick​}​>
  Prev
  </CarouselButton>
  <CarouselButton data​-​action=​"next"​ onClick=​{​​this​.handleNextClick​}​>
  Next
  </CarouselButton>
  </div>
  );
  }
 }
 
 export​ ​default​ HasIndex(Carousel, ​'slideIndex'​);

We don’t want slideIndexDecrement and slideIndexIncrement to be part of the rest props, since those are passed through to the DOM. Instead, this code pulls them out as variables prefixed with the underscore character, _. The underscore prefix is a convention that we’ll make use of momentarily.

ESLint isn’t too happy about the _slideIndexDecrement and _slideIndexIncrement variables this code creates as a side effect of keeping those props out of the rest spread. But this is the best available pattern. So, let’s modify the linter rules to ignore unused variables whose names start with an underscore:

 module.exports = {
  plugins: [​'react'​],
  extends: [​'eslint:recommended'​, ​'plugin:react/recommended'​],
  parser: ​'babel-eslint'​,
  env: {
  node: ​true​,
  },
  rules: {
  quotes: [​'error'​, ​'single'​, { avoidEscape: ​true​ }],
 'comma-dangle'​: [​'error'​, ​'always-multiline'​],
»'no-unused-vars'​: [​'error'​, { varsIgnorePattern: ​'^_'​ }],
  },
 };

There! The linter is happy. More importantly, you’ve successfully made Carousel a stateless component, with its state logic extracted to a reusable HOC. Time to commit your work, using the recommended gitmoji for refactoring:

 :recycle: Replace Carousel state with HasIndex HOC

This is a powerful refactoring. In the rest of the chapter, you’ll take advantage of this structure to add two new features to Carousel without making any modifications to the core component.

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

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