PL EN

Replace useState with typed useReducer

I have my own "unwritten" rule that if a component stores more than 4 different states, then I probably don't fully understand what I'm doing and should learn more or I need to separate the logic into useReducer.

Currently, many state-related problems are solved by very helpful libraries such as React Hook Form or Tanstack Query, but for the sake of showing how to type a reducer, we will mindlessly assume that the component is very well thought out and that the only refactor needed is to get rid of these states.

Let's say we have a magic widget that appears in our application under certain conditions and displays statistical data.

// widget.tsx
const [isVisible, setIsVisible] = useState(false)
const [data, setData] = useState<FetchedProject>(null)
const [dataType, setDataType] = useState<DataType>("all")
const [activeTab, setActiveTab] = useState<ActiveTab>("overview")

That's quite a lot of states, isn't it? Fortunately, we can clean up a bit.

// widget-reducer.ts
type DataType = "all" | "active" | "archived"
type ActiveTab = "overview" | "details" | "settings"
type FetchedProject = Project[] | null

export type WidgetState = {
  isVisible: boolean
  data: FetchedProject
  dataType: DataType
  activeTab: ActiveTab
}

export const initialWidgetState: WidgetState = {
  isVisible: false,
  data: null,
  dataType: "all",
  activeTab: "overview",
}

export type WidgetAction =
  | {
      type: "SET_DATA"
      payload: FetchedProject
    }
  | {
      type: "SET_IS_VISIBLE"
    }
  | {
      type: "SET_DATA_TYPE"
      payload: DataType
    }
  | {
      type: "SET_ACTIVE_TAB"
      payload: ActiveTab

export const widgetReducer = (state: WidgetState, action: WidgetAction) => {
  switch (action.type) {
    case "SET_DATA":
      return { ...state, data: action.payload }
    case "SET_IS_VISIBLE":
      return { ...state, isVisible: !state.isVisible }
    case "SET_DATA_TYPE": {
      const newDataType = action.payload
      const filtered =
        newDataType === "all"
          ? state.data
          : state.data?.filter((p) => p.dataType === newDataType)

      return { ...state, dataType: newDataType, data: filteredData ?? null }
    }
    case "SET_ACTIVE_TAB":
      return { ...state, activeTab: action.payload }
    default:
      throw new Error("Unhandled action type in WidgetReducer")
  }
}

Now we can clear useState from the component and replace it with useReducer.

// widget.tsx
const [state, dispatch] = useReducer(widgetReducer, initialWidgetState)

Examples of functions that cause state changes.

// widget.tsx
const handleWidgetVisibility = () => {
  dispatch({ type: "SET_IS_VISIBLE" })
}

const handleDataTypeChange = (dataType: DataType) => {
  dispatch({ type: "SET_DATA_TYPE", payload: dataType })
}

const handleActiveTabChange = (tab: ActiveTab) =>
  dispatch({ type: "SET_ACTIVE_TAB", payload: tab })

In this way, we hid the implementation details of the state inside the reducer, separated part of the logic from the component's presentation layer, and increased the readability of the code. The example is quite primitive, but the desired effect is to call only a simple action with business semantics, and the magic happens underneath.

Go back to articles