Published on

React Component Design - State Reducer

Authors
  • avatar
    Name
    Alex Yu
    Twitter

Render props讓使用者可以透過 state 去控制 UI 的顯示,

State Reducer則是讓使用者控制action觸發時的邏輯。

想像今天有需求要讓Toggle元件只能被 toggle 3 次,我們的程式碼該如何修改?

最直覺的做法可能是傳入一個 toggle 次數限制的 props 給 Toggle,讓 Toggle 去檢查是否有超過次數

class Toggle extends React.Component {
  static defaultProps = {
    onToggle: () => {},
    onReset: () => {},
    initialOn: false,
  };
  // 元件內部多一個 toggleTimes 來控制目前的 toggle 次數
  initialState = { on: this.props.initialOn, currentToggleTimes: 0 };

  state = this.initialState;

  reset = () =>
    this.setState(this.initialState, () =>
      this.props.onReset(this.initialState)
    );

  toggle = () => {
    // 每次 toggle 時判斷有沒有超過使用者定義的 toggle 次數上限
    if (this.state.currentToggleTimes >= this.props.toggleTimes) {
      console.log('toggle too much');
      return;
    }
    this.setState(
      ({ on }) => ({
        on: !on,
        currentToggleTimes: this.state.currentToggleTimes + 1,
      }),
      () => this.props.onToggle(this.state.on)
    );
  };
  // ...other methods
}

但使用者的需求總是會變動,假如他突然間也想控制 reset 的次數怎麼辦? 或者是使用者在點擊toggle的時候需要先打 api 或者根據別的資料跟狀態才能決定最後的on狀態,那我們不就改不完了?這時候可以換個角度思考,若能將Toggle的狀態往上傳出來給使用它的地方,讓使用它的地方決定Toggle最終的狀態,問題是不是就解決了?

stateReducer

來看看Toggle該怎麼被使用的範例

class Usage extends React.Component {
  static defaultProps = {
    onToggle: (...args) => console.log('onToggle', ...args),
    onReset: (...args) => console.log('onReset', ...args),
  };

  initialState = { timesClicked: 0 };

  state = this.initialState;

  handleToggle = (...args) => {
    this.setState(({ timesClicked }) => ({
      timesClicked: timesClicked + 1,
    }));
    this.props.onToggle(...args);
  };

  handleReset = (...args) => {
    this.setState(this.initialState);
    this.props.onReset(...args);
  };

  toggleStateReducer = (state, changes) => {
    if (this.state.timesClicked >= 4) {
      return { ...changes, on: false };
    }

    return changes;
  };

  render() {
    const { timesClicked } = this.state;

    return (
      <Toggle
        stateReducer={this.toggleStateReducer}
        onToggle={this.handleToggle}
        onReset={this.handleReset}
      >
        {(toggle) => (
          <div>
            <Switch
              {...toggle.getTogglerProps({
                on: toggle.on,
              })}
            />
            {timesClicked > 4 ? (
              <div data-testid="notice">
                Whoa, you clicked too much!
                <br />
              </div>
            ) : timesClicked > 0 ? (
              <div data-testid="click-count">Click count: {timesClicked}</div>
            ) : null}
            <button onClick={toggle.reset}>Reset</button>
          </div>
        )}
      </Toggle>
    );
  }
}

Toggle元件新增了一個 props 叫stateReducer, 第一個參數是 Toggle 元件目前的 state,第二個參數changes則是Toggle元件在執行setState時所接受的變化。

來看看Toggle元件的詳細實作

class Toggle extends React.Component {
  static defaultProps = {
    initialOn: false,
    onReset: () => {},
    stateReducer: (state, changes) => changes,
  };

  initialState = { on: this.props.initialOn };

  state = this.initialState;

  internalSetState(changes, callback) {
    this.setState((state) => {
      // handle function setState call
      const changesObject =
        typeof changes === 'function' ? changes(state) : changes;

      // apply state reducer
      const reducedChanges =
        this.props.stateReducer(state, changesObject) || {};

      // return null if there are no changes to be made
      // (to avoid an unecessary rerender)
      return Object.keys(reducedChanges).length ? reducedChanges : null;
    }, callback);
  }

  reset = () =>
    this.internalSetState(this.initialState, () =>
      this.props.onReset(this.state.on)
    );

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

  getTogglerProps = ({ onClick, ...props } = {}) => ({
    onClick: callAll(onClick, this.toggle),
    'aria-pressed': this.state.on,
    ...props,
  });

  getStateAndHelpers() {
    return {
      on: this.state.on,
      toggle: this.toggle,
      reset: this.reset,
      getTogglerProps: this.getTogglerProps,
    };
  }

  render() {
    return this.props.children(this.getStateAndHelpers());
  }
}

這邊提供原作者 Kent C. Dodds 偏好的internalSetState另外一種寫法

internalSetState(changes, callback) {
	this.setState(currentState => {
		return [changes]
			.map(c => typeof c === 'function' ? changes(currentState) : c)
      .map(c => this.props.stateReducer(currentState, c) || {})
      .map(c => Object.keys(reducedChanges).length ? c : null)[0]
	})
}

With Types

可能會有人疑問這個reducer的形狀怎麼跟[redux](https://redux.js.org/)的樣子不太一樣,應該要有typepayload之類的字眼,這邊實作加上type,先來看看怎麼使用Toggle

class Usage extends React.Component {
  static defaultProps = {
    onToggle: (...args) => console.log('onToggle', ...args),
    onReset: (...args) => console.log('onReset', ...args),
  };

  initialState = { timesClicked: 0 };

  state = this.initialState;

  handleToggle = (...args) => {
    this.setState(({ timesClicked }) => ({
      timesClicked: timesClicked + 1,
    }));
    this.props.onToggle(...args);
  };

  handleReset = (...args) => {
    this.setState(this.initialState);
    this.props.onReset(...args);
  };

  toggleStateReducer = (state, changes) => {
    if (changes.type === 'forced') {
      return changes;
    }
    if (this.state.timesClicked >= 4) {
      return { ...changes, on: false };
    }
    return changes;
  };

  render() {
    const { timesClicked } = this.state;

    return (
      <Toggle
        stateReducer={this.toggleStateReducer}
        onToggle={this.handleToggle}
        onReset={this.handleReset}
        ref={this.props.toggleRef}
      >
        {({ on, toggle, reset, getTogglerProps }) => (
          <div>
            <Switch
              {...getTogglerProps({
                on: on,
              })}
            />
            {timesClicked > 4 ? (
              <div data-testid="notice">
                Whoa, you clicked too much!
                <br />
                <button onClick={() => toggle({ type: 'forced' })}>
                  Force Toggle
                </button>
                <br />
              </div>
            ) : timesClicked > 0 ? (
              <div data-testid="click-count">Click count: {timesClicked}</div>
            ) : null}
            <button onClick={reset}>Reset</button>
          </div>
        )}
      </Toggle>
    );
  }
}

Toggle元件的實作如下

class Toggle extends React.Component {
  static defaultProps = {
    initialOn: false,
    onReset: () => {},
    stateReducer: (state, changes) => changes,
  };

  // 💰 any time I use a string as an identifier for a type,
  // I prefer to give it a variable name. That way folks who
  // want to reference the type can do so using variable which
  // will help mitigate the problems of indirection.
  static stateChangeTypes = {
    reset: '__toggle_reset__',
    toggle: '__toggle_toggle__',
  };

  initialState = { on: this.props.initialOn };

  state = this.initialState;

  internalSetState(changes, callback) {
    this.setState((state) => {
      // handle function setState call
      const changesObject =
        typeof changes === 'function' ? changes(state) : changes;

      // apply state reducer
      const reducedChanges =
        this.props.stateReducer(state, changesObject) || {};

      // remove the type so it's not set into state
      const { type: ignoredType, ...onlyChanges } = reducedChanges;

      // return null if there are no changes to be made
      return Object.keys(onlyChanges).length ? onlyChanges : null;
    }, callback);
  }

  reset = () =>
    this.internalSetState(
      { ...this.initialState, type: Toggle.stateChangeTypes.reset },
      () => this.props.onReset(this.state.on)
    );

  toggle = ({ type = Toggle.stateChangeTypes.toggle } = {}) =>
    this.internalSetState(
      ({ on }) => ({ type, on: !on }),
      () => this.props.onToggle(this.state.on)
    );

  getTogglerProps = ({ onClick, ...props } = {}) => ({
    onClick: callAll(onClick, () => this.toggle()),
    'aria-pressed': this.state.on,
    ...props,
  });

  getStateAndHelpers() {
    return {
      on: this.state.on,
      toggle: this.toggle,
      reset: this.reset,
      getTogglerProps: this.getTogglerProps,
    };
  }
  render() {
    return this.props.children(this.getStateAndHelpers());
  }
}