Refactor checkbox filters with React Custom Hook
See how you can extract logic to React Hook to make the code more generic. And manage the state of checkbox filters with ease!
On one of my projects, I had multiple sets of checkboxes, used for filtering vacation offers. There're filters for departure cities, destination places, board options, etc. Let's look together, at how we can achieve this using React ๐
Let's assume we store the state for each filter set as an array. So when a checkbox is checked, its value is added to the array. It looks like this:
board = []
// checking "All Inclusive" and "Breakfast"...
board = ['allInvlusive', 'breakfast']
// unchecking "Breakfast"
board = ['allInclusive']
# etc.
Additionally, we wanted to push the array with the state after each change to Redux, using onChange
callback. I hope it's quite clear, isn't it? Let's go to code and 1st iteration!
Step 1 - useState & useEffect per component
Let's start simple and implement storing state using useState
hook and calling onChange
callback under useEffect
. selectedBoard
is the array I mentioned earlier. It contains board options (IDs). Updating the state is performed by setSelectedBoard
with the jsxhelper function toggleItem
.
const [selectedBoard, setSelectedBoard] = useState<string[]>([])
useEffect(
() => {
onChange(selectedBoard)
}, [selectedBoard, onChange]
)
<input
type="checkbox"
name={ board.name }
value={ board.id }
checked={ isItemChecked(selectedBoard, board.id) }
onChange={ e => toggleItem(setSelectedBoard, e.target.value) }
/>
Nothing fancy. It just works ๐ The problem is that we'll have unnecessary code repetition in the future: with this approach, we have to copy useState
and useEffect
block for each filter component...
Fortunately, we can easily fix this, by extracting the hook logic to a more generic custom hook. What we'll do in the 2nd step.
Step 2 - Extract hook-related logic to a Custom Hook
The second step is all about extracting hook-related code to a separate file, wrapping it under useCheckboxes
function (naming is up to you, by convention it starts with use
) and return an object with functions related to the state.
Actually, useCheckboxes
could return anything, like the array, instead of the object. In this case, it makes sense to use an object, since I expect useChecbkoxes
to be growing, so it's much easier to import its functionalities via object keys.
useCheckboxes - Custom React Hook
As you can see, it uses the same useState
and useEffect
logic with the generic name Items
.
export const useCheckboxes = ({ onChange, defaultItems }: Props) => {
const [selectedItems, setSelectedItems] = useState<string[]>(defaultItems)
const isItemChecked = (itemId: string): boolean => {
return selectedItems.includes(itemId)
}
const toggleItem = (itemId: string): void => {
setSelectedItems((prevState: string[]) => {
if (prevState.includes(itemId)) {
return prevState.filter(item => item !== itemId)
}
else {
return [...prevState, itemId]
}
})
useEffect(
() => {
onChange(selectedItems)
}, [selectedItems, onChange]
)
return { isItemChecked, toggleItem }
Using the useCheckboxes custom hook
To use useCheckboxes
for the Board component, we just need to import the functions and make some minor updates.
const { isItemChecked, toggleItem } = useCheckboxes({ onChange, defaultItems: [] })
...
<input
type="checkbox"
name={ board.name }
value={ board.id }
checked={ isItemChecked(board.id) }
onChange={ e => toggleItem(e.target.value) }
/>
And that's it! Now we have a generic useCheckboxes
which can be easily used anywhere, without code repetition! Now, the code is much easier to maintain.
Also, it brings us a great bonus - now the checkbox logic can be easily tested.
useCheckboxes tests
import { renderHook, act } from '@testing-library/react-hooks'
import { useCheckboxes } from '../useCheckboxes'
const onChange = jest.fn()
it('#isItemCheck returns correct value', () => {
const { result } = renderHook(() => useCheckboxes({ onChange, defaultItems: ['XYZ'] }))
expect(result.current.isItemChecked('XYZ')).toBeTruthy()
expect(result.current.isItemChecked('ABC')).toBeFalsy()
})
it('#toggleItem toggles the given value and calls onChange', () => {
const { result } = renderHook(() => useCheckboxes({ onChange, defaultItems: ['XYZ'] }))
expect(onChange).toHaveBeenCalledTimes(1)
expect(result.current.isItemChecked('XYZ')).toBeTruthy()
expect(result.current.isItemChecked('ABC')).toBeFalsy()
act(() => result.current.toggleItem('ABC'))
expect(onChange).toHaveBeenCalledTimes(2)
expect(result.current.isItemChecked('ABC')).toBeTruthy()
expect(result.current.isItemChecked('XYZ')).toBeTruthy()
})
That's pretty nice! But, that's not the end. Let's suppose that we need to add some more features, like clearing an individual/all items (also without calling onChange
in some cases), pulling state from Redux, etc.
Handling state became more complex, so we can do one more refactor - use React useReducer
. And we'll do it!
Step 3 - useState + useEffect = useReducer
useReducer
hook manages state very similarly to Redux
. It dispatches actions, which are handled by the reducer, which returns a new state.
The biggest advantage of this approach is we have a finite set of actions and we can get rid of useEffect
, which by its asynchronous nature sometimes leads to unpredictable behavior (especially when managing a complex state).
Refactor to useCheckboxesReducer
As I mentioned, the reducer is a Redux-like function taking state
and action
as arguments. For clarity, I listed just part of the code.
export enum ActionType {
TOGGLE_ITEM = 'TOGGLE_ITEM',
CLEAR_ITEM = 'CLEAR_ITEM',
...
}
export const useCheckboxesReducer = (state: string[], action: useCheckboxesReducerAction) => {
switch (action.type) {
case ActionType.TOGGLE_ITEM: {
const { itemId } = action
const newState = state.includes(itemId) ? state.filter(item => item !== itemId) : [...state, itemId]
action.onChange(newState)
return newState
}
case ActionType.CLEAR_ITEM: {
const newState = state.filter(item => item !== action.itemId)
action.onChange(newState)
return newState
}
...
BTW, I like using enum
for declaring action types. It's easy to export/import etc. Bear in mind, it's a TypeScript feature.
Upadated useCheckboxes with useReducer
Now writing separate reducers pays off - useChecboxes
becomes a very lean function ๐. Its main goal is dispatching actions with demanded payload.
export const useCheckboxes = ({ onChange, defaultItems }: Props): HookReturn => {
const [checkedItems, dispatch] = useReducer(useCheckboxesReducer, defaultItems)
const isItemChecked = (itemId: string): boolean => checkedItems.includes(itemId)
const toggleItem = (itemId: string): void => dispatch({ type: ActionType.TOGGLE_ITEM, itemId, onChange })
const clearItem = (itemId: string): void => dispatch({ type: ActionType.CLEAR_ITEM, itemId, onChange })
...
return { isItemChecked, toggleItem, clearItem, ... }
}
Wrap up
We went on a journey from simple state management with useState
and useEffect
, through extracting it to custom hook, to more complex code with useReducer
.
We DRY-up the code and made it more maintainable and easy to test. Is this a good approach? I think so. But, only when you consider this as a path. Custom React Hook using useReducer
definitely is not a silver bullet and you should end up with that solution, only when it makes sense. Wondering when? ๐ค
My thoughts on this:
Managing a simple state, related to just one component? Basic
useState
anduseEffect
can make it.The state logic is relatively simple, but you use this in many components? Extract it to a custom hook!
When managing the state is like bull riding, rewrite the code with
useReducer
.
Just be pragmatic ๐