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