React Component Design - Control Props

發佈時間

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