工程師與貓
ESC
Content
    ↑↓ navigate open esc close
    Published on

    React Component Design - Control Props

    Authors
    • avatar
      Name
      Alex Yu

    一般來說,常見的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