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