Created
December 17, 2021 11:47
-
-
Save Neo42/6fc176b773947fa7db9cbeb5b9daab9c to your computer and use it in GitHub Desktop.
React Patterns: Control Props
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Control Props | |
import * as React from 'react' | |
import warning from 'warning' | |
import {Switch} from '../switch' | |
const mergeAll = | |
(...fns) => | |
(...args) => | |
fns.forEach(fn => fn?.(...args)) | |
const actionTypes = { | |
toggle: 'toggle', | |
reset: 'reset', | |
} | |
function toggleReducer(state, {type, initialState}) { | |
switch (type) { | |
case actionTypes.toggle: { | |
return {on: !state.on} | |
} | |
case actionTypes.reset: { | |
return initialState | |
} | |
default: { | |
throw new Error(`Unsupported type: ${type}`) | |
} | |
} | |
} | |
// Hooks for warning of abnormal use cases | |
function useControlledSwitchWarning( | |
controlPropValue, | |
controlPropName, | |
componentName, | |
) { | |
const isControlled = controlPropValue != null | |
const {current: wasControlled} = React.useRef(isControlled) | |
React.useEffect(() => { | |
warning( | |
!(isControlled && !wasControlled), | |
`\`${componentName}\` is changing from uncontrolled to be controlled. Components should not switch from uncontrolled to controlled (or vice versa). Decide between using a controlled or uncontrolled \`${componentName}\` for the lifetime of the component. Check the \`${controlPropName}\` prop.`, | |
) | |
warning( | |
!(!isControlled && wasControlled), | |
`\`${componentName}\` is changing from controlled to be uncontrolled. Components should not switch from controlled to uncontrolled (or vice versa). Decide between using a controlled or uncontrolled \`${componentName}\` for the lifetime of the component. Check the \`${controlPropName}\` prop.`, | |
) | |
}, [componentName, controlPropName, isControlled, wasControlled]) | |
} | |
function useOnChangeReadOnlyWarning( | |
controlPropValue, | |
controlPropName, | |
componentName, | |
hasOnChange, | |
readOnly, | |
readOnlyProp, | |
initialValueProp, | |
onChangeProp, | |
) { | |
const isControlled = controlPropValue != null | |
React.useEffect(() => { | |
warning( | |
!(!hasOnChange && isControlled && !readOnly), | |
`A \`${controlPropName}\` prop was provided to \`${componentName}\` without an \`${onChangeProp}\` handler. This will result in a read-only \`${controlPropName}\` value. If you want it to be mutable, use \`${initialValueProp}\`. Otherwise, set either \`${onChangeProp}\` or \`${readOnlyProp}\`.`, | |
) | |
}, [ | |
componentName, | |
controlPropName, | |
isControlled, | |
hasOnChange, | |
readOnly, | |
onChangeProp, | |
initialValueProp, | |
readOnlyProp, | |
]) | |
} | |
function useToggle({ | |
initialOn = false, | |
reducer = toggleReducer, | |
onChange, | |
on: controlledOn, | |
readOnly = false, | |
} = {}) { | |
const {current: initialState} = React.useRef({on: initialOn}) | |
const [state, dispatch] = React.useReducer(reducer, initialState) | |
const onIsControlled = controlledOn != null | |
const on = onIsControlled ? controlledOn : state.on | |
useControlledSwitchWarning(controlledOn, 'on', 'useToggle') | |
useOnChangeReadOnlyWarning( | |
controlledOn, | |
'on', | |
'useToggle', | |
Boolean(onChange), | |
readOnly, | |
'readOnly', | |
'initialOn', | |
'onChange', | |
) | |
function dispatchWithOnChange(action) { | |
// if `on` is not controlled, use local dispatch | |
if (!controlledOn) dispatch(action) | |
// otherwise set controlled `on` using global reducer | |
onChange?.(reducer({...state, on}, action), action) | |
} | |
const toggle = () => dispatchWithOnChange({type: actionTypes.toggle}) | |
const reset = () => | |
dispatchWithOnChange({type: actionTypes.reset, initialState}) | |
function getTogglerProps({onClick, ...props} = {}) { | |
return { | |
'aria-pressed': on, | |
onClick: mergeAll(onClick, toggle), | |
...props, | |
} | |
} | |
function getResetterProps({onClick, ...props} = {}) { | |
return { | |
onClick: mergeAll(onClick, reset), | |
...props, | |
} | |
} | |
return { | |
on, | |
reset, | |
toggle, | |
getTogglerProps, | |
getResetterProps, | |
} | |
} | |
function Toggle({on: controlledOn, onChange}) { | |
const {on, getTogglerProps} = useToggle({on: controlledOn, onChange}) | |
const props = getTogglerProps({on}) | |
return <Switch {...props} /> | |
} | |
function App() { | |
const [bothOn, setBothOn] = React.useState(false) | |
const [timesClicked, setTimesClicked] = React.useState(0) | |
function handleToggleChange(state, action) { | |
if (action.type === actionTypes.toggle && timesClicked > 4) { | |
return | |
} | |
// get controlled prop | |
setBothOn(state.on) | |
setTimesClicked(c => c + 1) | |
} | |
function handleResetClick() { | |
setBothOn(false) | |
setTimesClicked(0) | |
} | |
return ( | |
<div> | |
<div> | |
<Toggle on={bothOn} onChange={handleToggleChange} /> | |
<Toggle on={bothOn} onChange={handleToggleChange} /> | |
</div> | |
{timesClicked > 4 ? ( | |
<div data-testid="notice"> | |
Whoa, you clicked too much! | |
<br /> | |
</div> | |
) : ( | |
<div data-testid="click-count">Click count: {timesClicked}</div> | |
)} | |
<button onClick={handleResetClick}>Reset</button> | |
<hr /> | |
<div> | |
<div>Uncontrolled Toggle:</div> | |
<Toggle | |
onChange={(...args) => | |
console.info('Uncontrolled Toggle onChange', ...args) | |
} | |
/> | |
</div> | |
</div> | |
) | |
} | |
export default App |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment