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
不會有任何改變。
讓我們將之前的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
可以去監聽Toggle
的state
,
換言之,Toggle
的state
改變時,可以被上層使用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
。