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.
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 |
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:
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.
3.19.56.45