Advanced React state hooks
React's toolbox is intentionally quite limited, providing you some very versatile building blocks to create your own abstractions. But, if you find useState()
too limited for your needs, and useReducer()
doesn't quite cut it either, how do you go about creating more advanced state management hooks? Let's deep dive into some advanced state management hooks.
useToggler
hook
Starting off with a simple one, the useToggler
hook provides a boolean state variable that can be toggled between its two states. Instead of managing the state manually, you can simply call the toggleValue
function to toggle the state.
The implementation is rather simple, as well. You use the useState()
hook to create the value
state variable and its setter. Then, you create a function that toggles the value of the state variable and memoize it, using the useCallback()
hook. Finally, you return the value
state variable and the memoized function.
const useToggler = initialState => { const [value, setValue] = React.useState(initialState); const toggleValue = React.useCallback(() => setValue(prev => !prev), []); return [value, toggleValue]; }; const Switch = () => { const [val, toggleVal] = useToggler(false); return <button onClick={toggleVal}>{val ? 'ON' : 'OFF'}</button>; }; ReactDOM.createRoot(document.getElementById('root')).render( <Switch /> );
useMap
hook
The Map
object is a very versatile data structure in JavaScript, but it's not directly supported by React's state management hooks. The useMap
hook creates a stateful Map
object and a set of functions to manipulate it.
Using the useState()
hook and the Map()
constructor, you create a new Map
from the initialValue
. Then, you use the useMemo()
hook to create a set of non-mutating actions that manipulate the state variable, using the state setter to create a new Map
every time. Finally, you return the map
state variable and the created actions
.
const useMap = initialValue => { const [map, setMap] = React.useState(new Map(initialValue)); const actions = React.useMemo( () => ({ set: (key, value) => setMap(prevMap => { const nextMap = new Map(prevMap); nextMap.set(key, value); return nextMap; }), remove: (key, value) => setMap(prevMap => { const nextMap = new Map(prevMap); nextMap.delete(key, value); return nextMap; }), clear: () => setMap(new Map()), }), [setMap] ); return [map, actions]; }; const MyApp = () => { const [map, { set, remove, clear }] = useMap([['apples', 10]]); return ( <div> <button onClick={() => set(Date.now(), new Date().toJSON())}>Add</button> <button onClick={() => clear()}>Reset</button> <button onClick={() => remove('apples')} disabled={!map.has('apples')}> Remove apples </button> <pre> {JSON.stringify( [...map.entries()].reduce( (acc, [key, value]) => ({ ...acc, [key]: value }), {} ), null, 2 )} </pre> </div> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <MyApp /> );
useSet
hook
Similar to useMap
, the useSet
hook creates a stateful Set
object and a set of functions to manipulate it. The implementation is very similar to the previous hook, but instead of using a Map
, you use a Set
.
const useSet = initialValue => { const [set, setSet] = React.useState(new Set(initialValue)); const actions = React.useMemo( () => ({ add: item => setSet(prevSet => new Set([...prevSet, item])), remove: item => setSet(prevSet => new Set([...prevSet].filter(i => i !== item))), clear: () => setSet(new Set()), }), [setSet] ); return [set, actions]; }; const MyApp = () => { const [set, { add, remove, clear }] = useSet(new Set(['apples'])); return ( <div> <button onClick={() => add(String(Date.now()))}>Add</button> <button onClick={() => clear()}>Reset</button> <button onClick={() => remove('apples')} disabled={!set.has('apples')}> Remove apples </button> <pre>{JSON.stringify([...set], null, 2)}</pre> </div> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <MyApp /> );
useDefault
hook
Similar to the previous hook, we might also need a hook that provides a default value if the state is null
or undefined
. The useDefault
hook does exactly that. It creates a stateful value with a default fallback if it's null
or undefined
, and a function to update it.
The approach is very similar to the previous hook. You use the useState()
hook to create the stateful value. Then, you check if the value is either null
or undefined
. If it is, you return the defaultState
, otherwise you return the actual value
state, alongside the setValue
function.
const useDefault = (defaultState, initialState) => { const [value, setValue] = React.useState(initialState); const isValueEmpty = value === undefined || value === null; return [isValueEmpty ? defaultState : value, setValue]; }; const UserCard = () => { const [user, setUser] = useDefault({ name: 'Adam' }, { name: 'John' }); return ( <> <div>User: {user.name}</div> <input onChange={e => setUser({ name: e.target.value })} /> <button onClick={() => setUser(null)}>Clear</button> </> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <UserCard /> );
useGetSet
hook
Instead of returning a single state variable and its setter, you might want to return a getter and a setter function. This is the job of the useGetSet
hook. It creates a stateful value, returning a getter and a setter function.
In order to implement this hook, you use the useRef()
hook to create a ref that holds the stateful value, initializing it with initialState
. Then, you use the useReducer()
hook that creates a new object every time it's updated and return its dispatch.
Finally, you use the useMemo()
hook to memoize a pair of functions. The first one will return the current value of the state
ref and the second one will update it and force a re-render.
const useGetSet = initialState => { const state = React.useRef(initialState); const [, update] = React.useReducer(() => ({})); return React.useMemo( () => [ () => state.current, newState => { state.current = newState; update(); }, ], [] ); }; const Counter = () => { const [getCount, setCount] = useGetSet(0); const onClick = () => { setTimeout(() => { setCount(getCount() + 1); }, 1_000); }; return <button onClick={onClick}>Count: {getCount()}</button>; }; ReactDOM.createRoot(document.getElementById('root')).render( <Counter /> );
useMergeState
hook
Similar to the useReducer()
hook, the useMergeState
hook allows you to update the state by merging the new state provided with the existing one. It creates a stateful value and a function to update it by merging the new state provided.
All you need to do to implement it is use the useState()
hook to create a state variable, initializing it to initialState
. Then, create a function that will update the state variable by merging the new state provided with the existing one. If the new state is a function, call it with the previous state as the argument and use the result.
Omit the argument to initialize the state variable with an empty object ({}
).
const useMergeState = (initialState = {}) => { const [value, setValue] = React.useState(initialState); const mergeState = newState => { if (typeof newState === 'function') newState = newState(value); setValue({ ...value, ...newState }); }; return [value, mergeState]; }; const MyApp = () => { const [data, setData] = useMergeState({ name: 'John', age: 20 }); return ( <> <input value={data.name} onChange={e => setData({ name: e.target.value })} /> <button onClick={() => setData(({ age }) => ({ age: age - 1 }))}> - </button> {data.age} <button onClick={() => setData(({ age }) => ({ age: age + 1 }))}> + </button> </> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <MyApp /> );
usePrevious
hook
The usePrevious
hook is a very useful hook that stores the previous state or props. It's a custom hook that takes a value
and returns the previous value. It uses the useRef()
hook to create a ref for the value
and the useEffect()
hook to remember the latest value
.
const usePrevious = value => { const ref = React.useRef(); React.useEffect(() => { ref.current = value; }); return ref.current; }; const Counter = () => { const [value, setValue] = React.useState(0); const lastValue = usePrevious(value); return ( <div> <p> Current: {value} - Previous: {lastValue} </p> <button onClick={() => setValue(value + 1)}>Increment</button> </div> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <Counter /> );
useDebounce
hook
Similar to the usePrevious
hook, the useDebounce
hook is a custom hook that debounces the given value. It takes a value
and a delay
and returns the debounced value. It uses the useState()
hook to store the debounced value and the useEffect()
hook to update the debounced value every time the value
is updated.
Using setTimeout()
, it creates a timeout that delays invoking the setter of the previous state variable by delay
milliseconds. Then, it uses clearTimeout()
to clean up when dismounting the component. This is particularly useful when dealing with user input.
const useDebounce = (value, delay) => { const [debouncedValue, setDebouncedValue] = React.useState(value); React.useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value]); return debouncedValue; }; const Counter = () => { const [value, setValue] = React.useState(0); const lastValue = useDebounce(value, 500); return ( <div> <p> Current: {value} - Debounced: {lastValue} </p> <button onClick={() => setValue(value + 1)}>Increment</button> </div> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <Counter /> );
useDelayedState
hook
Instead of creating a stateful value immediately, you might want to delay its creation until some condition is met. This is where the useDelayedState
hook comes in. It creates a stateful value that is only updated if the condition
is met.
Implementing this hook requires the use of the useState()
and useEffect()
hooks. You create a stateful value containing the actual state
and a boolean, loaded
. Then, you update the stateful value if the condition
or loaded
changes. Finally, you create a function, updateState
, that only updates the state
value if loaded
is truthy.
const useDelayedState = (initialState, condition) => { const [{ state, loaded }, setState] = React.useState({ state: null, loaded: false, }); React.useEffect(() => { if (!loaded && condition) setState({ state: initialState, loaded: true }); }, [condition, loaded]); const updateState = newState => { if (!loaded) return; setState({ state: newState, loaded }); }; return [state, updateState]; }; const App = () => { const [branches, setBranches] = React.useState([]); const [selectedBranch, setSelectedBranch] = useDelayedState( branches[0], branches.length ); React.useEffect(() => { const handle = setTimeout(() => { setBranches(['master', 'staging', 'test', 'dev']); }, 2000); return () => { handle && clearTimeout(handle); }; }, []); return ( <div> <p>Selected branch: {selectedBranch}</p> <select onChange={e => setSelectedBranch(e.target.value)}> {branches.map(branch => ( <option key={branch} value={branch}> {branch} </option> ))} </select> </div> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <App /> );
useForm
hook
Last but not least, the useForm
hook can be used to create a stateful value from the fields in a form. It uses the useState()
hook to create a state variable for the values in the form and a function that will be called with an appropriate event by a form field to update the state variable accordingly.
const useForm = initialValues => { const [values, setValues] = React.useState(initialValues); return [ values, e => { setValues({ ...values, [e.target.name]: e.target.value }); } ]; }; const Form = () => { const initialState = { email: '', password: '' }; const [values, setValues] = useForm(initialState); const handleSubmit = e => { e.preventDefault(); console.log(values); }; return ( <form onSubmit={handleSubmit}> <input type="email" name="email" onChange={setValues} /> <input type="password" name="password" onChange={setValues} /> <button type="submit">Submit</button> </form> ); }; ReactDOM.createRoot(document.getElementById('root')).render( <Form /> );