Published on

React Component Design - Control Props

Authors
  • avatar
    Name
    Alex Yu
    Twitter

一般來說,常見的Form元件: <select>, <input>, <textarea>,當使用者在輸入時,通常改變的是元件內部他們自己的state。但在React的世界裡,state的改變通常是透過setState的做法,確保single source of truth。為此,可以把state傳給Form的元件value props,如下圖,透過這樣的方式控制value,並且要傳入change handler props去改變value,不然在Form元件輸入後value不會有任何改變。

Controlled Component https://legacy.reactjs.org/docs/forms.html#controlled-components

讓我們將之前的Toggle元件用Controlled Component的方式改寫

class Toggle extends React.Component {
  state = { on: false };

  isControlled(prop) {
    return this.props[prop] !== undefined;
  }

  getState() {
    return {
      on: this.isControlled('on') ? this.props.on : this.state.on,
    };
  }

  toggle = () => {
    if (this.isControlled('on')) {
      this.props.onToggle(!this.getState().on);
    } else {
      this.setState(
        ({ on }) => ({ on: !on }),
        () => {
          this.props.onToggle(this.getState().on);
        }
      );
    }
  };

  render() {
    return <Switch on={this.getState().on} onClick={this.toggle} />;
  }
}

class Usage extends React.Component {
  state = { bothOn: false };

  handleToggle = (on) => {
    this.setState({ bothOn: on });
  };

  render() {
    const { bothOn } = this.state;
    const { toggle1Ref, toggle2Ref } = this.props;

    return (
      <div>
        <Toggle on={bothOn} onToggle={this.handleToggle} ref={toggle1Ref} />
        <Toggle on={bothOn} onToggle={this.handleToggle} ref={toggle2Ref} />
      </div>
    );
  }
}

改寫 getState

上面這樣的方式適用於state只有on的時候,如果有多個state的時候就會顯得有點冗長

我們可以改寫上面的getState

class Toggle extends React.Component {
  // same as before

  getState() {
    return Object.entries(this.state).reduce((combinedState, [key, value]) => {
      if (this.isControlled(key)) {
        combinedState[key] = this.props[key];
      } else {
        combinedState[key] = value;
      }

      return combinedState;
    }, {});
  }

  // same as before
}

監聽 Toggle State

如果我們希望有一個function handler可以去監聽Togglestate

換言之,Togglestate改變時,可以被上層使用Toggle元件的地方接收到訊息,讓介面長得像下方

class Usage extends React.Component {
  state = { bothOn: false };

  handleStateChange = ({ on }) => {
    this.setState({ bothOn: on });
  };

  render() {
    const { bothOn } = this.state;
    const { toggle1Ref, toggle2Ref } = this.props;

    return (
      <div>
        <Toggle
          on={bothOn}
          onStateChange={this.handleStateChange}
          ref={toggle1Ref}
        />
        <Toggle
          on={bothOn}
          onStateChange={this.handleStateChange}
          ref={toggle2Ref}
        />
      </div>
    );
  }
}

class Toggle extends React.Component {
  static defaultProps = {
    onToggle: () => {},
    onStateChange: () => {},
  };

  state = { on: false };

  isControlled(prop) {
    return this.props[prop] !== undefined;
  }

  // 將state做為參數傳入,確保可以拿到當下state的值
  getState(state = this.state) {
    return Object.entries(state).reduce((combinedState, [key, value]) => {
      if (this.isControlled(key)) {
        combinedState[key] = this.props[key];
      } else {
        combinedState[key] = value;
      }
      return combinedState;
    }, {});
  }

  // Toggle內setState的行為應該都透過這個function去執行
  internalSetState(changes, callback) {
    let allChanges;

    this.setState(
      (state) => {
        const combinedState = this.getState(state);
        const changesObject =
          typeof changes === 'function' ? changes(combinedState) : changes;

        allChanges = changesObject;

        const nonControlledChanges = Object.entries(changesObject).reduce(
          (newChanges, [key, value]) => {
            if (!this.isControlled(key)) {
              newChanges[key] = value;
            }
            return newChanges;
          },
          {}
        );

        return Object.keys(nonControlledChanges).length
          ? nonControlledChanges
          : null;
      },
      () => {
        this.props.onStateChange(allChanges);
        callback();
      }
    );
  }

  toggle = () => {
    this.internalSetState(
      ({ on }) => ({ on: !on }),
      () => {
        this.props.onToggle(this.getState().on);
      }
    );
  };

  render() {
    return <Switch on={this.getState().on} onClick={this.toggle} />;
  }
}

改寫 internalSetState

internalSetState在上面的例子雖然有把狀態傳出去給上層,但上層卻無法得知是什麼樣的事件觸發了internalSetState,這邊我們可以利用跟stateReducer類似的技巧,在觸發internalSetState的時候傳入type,這樣上層便可以得知是什麼樣的事件觸發 state change

class Toggle extends React.Component {
  static stateChangeTypes = {
    toggle: '__toggle__',
    toggleOn: '__toggle_on__',
    toggleOff: '__toggle_off__',
  };

  // same as before

  internalSetState(changes, callback = () => {}) {
    let allChanges;

    this.setState(
      (state) => {
        const combinedState = this.getState(state);
        const changesObject =
          typeof changes === 'function' ? changes(combinedState) : changes;

        allChanges = changesObject;

        // 將type destruct, 避免type被setState進state
        const { type: ignoredType, ...onlyChanges } = changesObject;

        const nonControlledChanges = Object.entries(onlyChanges).reduce(
          (newChanges, [key, value]) => {
            if (!this.isControlled(key)) {
              newChanges[key] = value;
            }
            return newChanges;
          },
          {}
        );

        return Object.keys(nonControlledChanges).length
          ? nonControlledChanges
          : null;
      },
      () => {
        this.props.onStateChange(allChanges, this.getState());
        callback();
      }
    );
  }

  toggle = ({ on: newState, type = Toggle.stateChangeTypes.toggle } = {}) => {
    this.internalSetState(
      ({ on }) => ({
        on: typeof newState === 'boolean' ? newState : !on,
        type,
      }),
      () => {
        this.props.onToggle(this.getState().on);
      }
    );
  };

  handleSwitchClick = () => this.toggle();

  handleOffClick = () =>
    this.toggle({
      on: false,
      type: Toggle.stateChangeTypes.toggleOff,
    });

  handleOnClick = () =>
    this.toggle({
      on: true,
      type: Toggle.stateChangeTypes.toggleOn,
    });

  render() {
    return (
      <div>
        <Switch on={this.getState().on} onClick={this.handleSwitchClick} />
        <button onClick={this.handleOffClick}>off</button>
        <button onClick={this.handleOnClick}>on</button>
      </div>
    );
  }
}

class Usage extends React.Component {
  state = { bothOn: false };

  lastWasButton = false;

  handleStateChange = (changes) => {
    const isButtonChange =
      changes.type === Toggle.stateChangeTypes.toggleOn ||
      changes.type === Toggle.stateChangeTypes.toggleOff;

    if (
      changes.type === Toggle.stateChangeTypes.toggle ||
      (this.lastWasButton && isButtonChange)
    ) {
      this.setState({ bothOn: changes.on });
      this.lastWasButton = false;
    } else {
      this.lastWasButton = isButtonChange;
    }
  };

  render() {
    const { bothOn } = this.state;
    const { toggle1Ref, toggle2Ref } = this.props;

    return (
      <div>
        <Toggle
          on={bothOn}
          onStateChange={this.handleStateChange}
          ref={toggle1Ref}
        />
        <Toggle
          on={bothOn}
          onStateChange={this.handleStateChange}
          ref={toggle2Ref}
        />
      </div>
    );
  }
}

結論

Control Props能用在元件在不傳入value時,仍能保有自身的state,但如果parent component想要控制元件的value時仍然保有一定的彈性,並且能夠讓parent component監聽自身state改變時的event handler