React Component Design - State Reducer

發佈時間

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()); } }