Skip to content

Instantly share code, notes, and snippets.

@Neo42
Created December 17, 2021 11:47
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Neo42/6fc176b773947fa7db9cbeb5b9daab9c to your computer and use it in GitHub Desktop.
Save Neo42/6fc176b773947fa7db9cbeb5b9daab9c to your computer and use it in GitHub Desktop.
React Patterns: Control Props
// 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