Refactor checkbox filters with React Custom Hook

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!

ยท

5 min read

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.

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:

  1. Managing a simple state, related to just one component? Basic useState and useEffect can make it.

  2. The state logic is relatively simple, but you use this in many components? Extract it to a custom hook!

  3. When managing the state is like bull riding, rewrite the code with useReducer.

Just be pragmatic ๐Ÿ‘

ย