The Controllable Pattern

Components in React are commonly described as controlled or uncontrolled with respect to some variable. If that variable is passed down to it through props, the component is controlled. If that variable is managed as state, the component is uncontrolled.

In its original incarnation, Carousel was uncontrolled with respect to slideIndex. With the HasIndex refactoring, the core of Carousel is now controlled, but the overall component—the version of Carousel that this library’s users will consume—is still uncontrolled. Nothing outside of Carousel can modify its slideIndex, because that variable is kept in state.

Suppose you removed the HasIndex wrapper to make Carousel controlled. That would make the component more versatile, since the user could change the slideIndex freely. But it would also make it more cumbersome, since the user would have to implement their own slideIndexDecrement and slideIndexIncrement handlers.

What if you could get the best of both worlds? That’s what the controllable pattern offers. As its name suggests, a controllable component is one that can be optionally controlled. If the user chooses not to control the variable in question, then it functions as an uncontrolled component. The controllable pattern is exemplified by React’s own wrappers around form elements, e.g. <input>.

In this section, you will modify HasIndex to make the slideIndex on Carousel controllable.

Implementing Controllable Behaviors

Making Carousel controllable entails accepting a slideIndex prop with the following behavior:

  1. If slideIndex is undefined, it continues to function the way it always has, changing slideIndex internally when the Prev/Next buttons are clicked.

  2. If slideIndex is defined, it overrides any internal state.

One implication of this is that the Prev/Next buttons will no longer be able to change the effective slideIndex if the prop is set. Instead, they should trigger a change event, conventionally named onSlideIndexChange. This event gives whoever is controlling the Carousel the option to update the slideIndex prop.

This is a common source of confusion, so it bears emphasis: when onSlideIndexChange is called, it does not necessarily mean that the slideIndex has changed. Really it can mean two things: in the uncontrolled case, it means slideIndex (the internal value) has changed; in the controlled case, it means slideIndex would change if the component were uncontrolled, and the controller has the option to change the slideIndex prop accordingly. A fundamental rule of React is that components have no power to change their own props.

You can express all of these requirements as tests against HasIndex:

 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 initial `index` state equal to the `defaultIndex` prop'​, () => {
» expect(wrapper.state(​'index'​)).toBe(0);
»const​ wrapper2 = shallow(<MockComponentWithIndex defaultIndex=​{​1​}​ />);
» expect(wrapper2.state(​'index'​)).toBe(1);
» });
»
» it(​'always has `index` state equal to the `index` prop'​, () => {
»const​ wrapperWithInitialIndex = shallow(
» <MockComponentWithIndex index=​{​1​}​ />
» );
» expect(wrapperWithInitialIndex.state(​'index'​)).toBe(1);
» wrapper.setProps({ index: 2 });
» expect(wrapper.state(​'index'​)).toBe(2);
» });
»
» it(​'allows `index` state to change if the `index` prop is unset'​, () => {
»const​ wrapperWithInitialIndex = shallow(
» <MockComponentWithIndex index=​{​1​}​ />
» );
» wrapperWithInitialIndex.setProps({ index: ​undefined​ });
» wrapperWithInitialIndex.setState({ index: 3 });
» expect(wrapperWithInitialIndex.state(​'index'​)).toBe(3);
» });
»
» it(​'calls `onIndexChange` on decrement/increment'​, () => {
»const​ onIndexChange = jest.fn();
» wrapper.setProps({ index: 0, onIndexChange });
» wrapper.prop(​'indexDecrement'​)(3);
» expect(onIndexChange).toHaveBeenCalledWith({ target: { value: 2 } });
» wrapper.prop(​'indexIncrement'​)(3);
» expect(onIndexChange).toHaveBeenCalledWith({ target: { value: 1 } });
» });
 
  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);
  });
 });

It’s standard for controllable components to provide a prop prefixed with default (not to be confused with a default prop) that allows the user to set the initial value without having to use the controlled pattern.

This test shows the index prop in action, and how it always overwrites the internal state when set.

Here we’re testing that the component can switch back and forth between being controlled and uncontrolled. This is a common use case: the component consumer might want to “freeze” the slides until the user takes some action by setting slideIndex, then unset it to restore the normal slide behavior.

This is another mock function test. expect(mockFunction).toHaveBeenCalledWith() makes an assertion about the most recent call to mockFunction. Notice that the expected argument to the change handler has the same shape as a DOM event; this is a standard convention in React.

Now you’re ready to implement it:

 import​ React ​from​ ​'react'​;
»import​ PropTypes ​from​ ​'prop-types'​;
 
»const​ capitalize = word => ​`​${word[0].toUpperCase()}${word.slice(1)}​`​;
 
»export​ ​default​ (Component, indexPropName) => {
»const​ defaultIndexPropName = ​`default​${capitalize(indexPropName)}​`​;
 
 return​ ​class​ ComponentWithIndex ​extends​ React.PureComponent {
 static​ displayName = ​`HasIndex(​${Component.displayName ||
  Component.name}​)`​;
 
»static​ propTypes = {
» [indexPropName]: PropTypes.number,
» [defaultIndexPropName]: PropTypes.number,
» onIndexChange: PropTypes.func,
» };
»
»static​ defaultProps = {
» [defaultIndexPropName]: 0,
» };
»
»static​ getDerivedStateFromProps(props, state) {
»if​ (
» props[indexPropName] != ​null​ &&
» props[indexPropName] !== state.index
» ) {
»return​ { index: props[indexPropName] };
» }
»return​ ​null​;
» }
»
»constructor​(props) {
»super​(props);
»
»this​.state = {
» index: props[defaultIndexPropName],
» };
» }
  handleDecrement = upperBound => {
»const​ { onIndexChange } = ​this​.props;
 this​.setState(({ index }) => {
 const​ newIndex = upperBound
  ? (index + upperBound - 1) % upperBound
  : index - 1;
»if​ (onIndexChange) {
» onIndexChange({ target: { value: newIndex } });
» }
 return​ {
  index: newIndex,
  };
  });
  };
 
  handleIncrement = upperBound => {
»const​ { onIndexChange } = ​this​.props;
 this​.setState(({ index }) => {
 const​ newIndex = upperBound ? (index + 1) % upperBound : index + 1;
»if​ (onIndexChange) {
» onIndexChange({ target: { value: newIndex } });
» }
 return​ {
  index: newIndex,
  };
  });
  };
 
  render() {
»const​ {
» [defaultIndexPropName]: _defaultIndexProp,
» ...rest
» } = ​this​.props;
»const​ indexProps = {
» [indexPropName]: ​this​.state.index,
» [​`​${indexPropName}​Decrement`​]: ​this​.handleDecrement,
» [​`​${indexPropName}​Increment`​]: ​this​.handleIncrement,
» };
 return​ <Component ​{​...rest​}​ ​{​...indexProps​}​ />;
  }
  };
 };

Since the index prop name is set at runtime, we need a bit of string manipulation to compute the name of its default-prefixed analogue. For example, the slideIndex prop will be accompanied by defaultSlideIndex.

This code takes advantage of a new ES6 feature called “computed property names.”[65] The expression inside of the square brackets is evaluated to determine the key.

Added in React 16.3, getDerivedStateFromProps() is a lifecycle method called before every render. Here it’s used to overwrite state.index with the index prop value when necessary. If no state changes are needed, it does nothing by returning null.

Since the initial state is now derived from the initial props, we can no longer define it with the class property syntax. Instead we need to do so in a constructor. React constructors must call super(props), for reasons that are too esoteric to go into here.[66]

With that, Carousel is now controllable! Just to be sure, try adding a mount test to Carousel:

 // src/tests/Carousel.test.js
 import​ React ​from​ ​'react'​;
»import​ { mount, shallow } ​from​ ​'enzyme'​;
 import​ Carousel, { Carousel ​as​ CoreCarousel } ​from​ ​'../Carousel'​;
 import​ CarouselButton ​from​ ​'../CarouselButton'​;
 import​ CarouselSlide ​from​ ​'../CarouselSlide'​;
 
 describe(​'Carousel'​, () => {
  ...
  describe(​'component with HOC'​, () => {
  ...
» it(​'allows `slideIndex` to be controlled'​, () => {
»const​ 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);
» });
  ...
  });
  ...
 });

Commit your work:

 :sparkles: Allow the index in HasIndex to be controlled
..................Content has been hidden....................

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