useReducer
Move several interdependent fields into one pure reducer function instead of juggling five separate setState calls.
const [state, dispatch] = useReducer(reducer, initialArg, init?)useReducer is the other way to hold state in a component. You give it a pure function, the reducer, and it hands back the current state and a dispatch function: const [state, dispatch] = useReducer(reducer, initialArg, init?). Calling dispatch(action) runs reducer(state, action) and stores the result. An action is any value; by convention an object with a type field.
Why reach for it
Use it when several fields move together. With five useState calls, one transition is spread across five setters and every call site has to remember all of them, which is how a screen ends up in a state that should be impossible. With a reducer, each transition is one named case written once, and reading the reducer tells you every way the state can change.
Writing one
The reducer must be pure: build a new object, never mutate the one passed in. { ...state, error: null } is the usual spread, and Object.assign({}, state, { error: null }) is the same thing written long. No fetching, no timers, no navigation inside it; those belong in the handler or an effect. The optional third argument builds the first state once, as init(initialArg).
Gotchas
dispatch has a stable identity, so it is safe to leave out of an effect's dependency array and fine to pass down the tree. It returns nothing: reading state on the line after dispatching gives the old value, because the new one arrives on the next render.
Example
// A centred form: two password boxes and a button. Submitting with a short or mismatched
// password shows a red line above the button; editing either box clears it again.
import { useReducer } from "react";
import { Pressable, StyleSheet, Text, TextInput, View } from "react-native";
type State = { password: string; confirm: string; error: string | null };
type Action =
| { type: "password"; value: string }
| { type: "confirm"; value: string }
| { type: "submit" };
const initialState: State = { password: "", confirm: "", error: null };
// Pure: the same state and action always give the same result. No fetching, no timers.
function reducer(state: State, action: Action): State {
switch (action.type) {
case "password":
// Editing clears the error too, so the message can never outlive the value it described.
return { ...state, password: action.value, error: null };
case "confirm":
return { ...state, confirm: action.value, error: null };
case "submit":
if (action.type === "submit" && state.password.length < 8) {
return { ...state, error: "Use at least 8 characters" };
}
if (state.password !== state.confirm) {
return { ...state, error: "Passwords do not match" };
}
return { ...state, error: null };
}
}
export default function PasswordForm() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<View style={styles.screen}>
<TextInput
value={state.password}
onChangeText={(value) => dispatch({ type: "password", value })}
placeholder="Password"
secureTextEntry
style={styles.input}
/>
<TextInput
value={state.confirm}
onChangeText={(value) => dispatch({ type: "confirm", value })}
placeholder="Repeat password"
secureTextEntry
style={styles.input}
/>
{state.error !== null ? <Text style={styles.error}>{state.error}</Text> : null}
<Pressable onPress={() => dispatch({ type: "submit" })} style={styles.button}>
<Text>Create account</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, justifyContent: "center", padding: 24, gap: 12 },
input: { borderWidth: 1, borderColor: "#cccccc", borderRadius: 8, padding: 12 },
error: { color: "#b00020" },
button: { alignItems: "center", padding: 14, borderRadius: 8, backgroundColor: "#e5e5e5" },
});When to use it
- Hold a multi-field form where validation errors depend on more than one field.
- Drive a wizard whose step, answers and ability to continue must change together.
- Replace a set of setters that callers keep forgetting to update as a group.
- Move screen logic into a pure function so it can be unit tested without rendering.
More examples
Five setters against one reducer
The same timer state written both ways, with no UI, so the difference is only in the shape. Reach for the reducer version as soon as one user action has to change more than two fields at once: the restart logic exists in exactly one place and a new transition is a new case rather than a new call site to audit.
import { useReducer, useState } from "react";
type Status = "idle" | "running" | "done";
// Three useState calls. Every transition has to touch all of them, and any caller that
// forgets one leaves the screen in a combination that should not exist.
export function useTimerWithState() {
const [status, setStatus] = useState<Status>("idle");
const [seconds, setSeconds] = useState(0);
const [laps, setLaps] = useState<number[]>([]);
const start = () => {
setStatus("running");
setSeconds(0);
setLaps([]); // forget this line and the old laps survive a restart
};
return { status, seconds, laps, start };
}
type State = { status: Status; seconds: number; laps: number[] };
type Action = { type: "start" } | { type: "tick" } | { type: "lap" } | { type: "stop" };
// One reducer. Each transition is written once and the three fields can only move together.
function reducer(state: State, action: Action): State {
switch (action.type) {
case "start":
return { status: "running", seconds: 0, laps: [] };
case "tick":
return { ...state, seconds: state.seconds + 1 };
case "lap":
return { ...state, laps: [...state.laps, state.seconds] };
case "stop":
return { ...state, status: "done" };
}
}
export function useTimer() {
const [state, dispatch] = useReducer(reducer, { status: "idle", seconds: 0, laps: [] });
// dispatch is stable, so these wrappers never change identity either.
return { ...state, start: () => dispatch({ type: "start" }), tick: () => dispatch({ type: "tick" }) };
}The third argument: building the initial state once
On screen: a line reading "Question 1 of 5" with a Next button that advances it and stops at the last question. The third argument matters when the initial state is expensive or depends on a prop, which is the usual case for a wizard whose length is known only at runtime.
import { useReducer } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
type State = { step: number; answers: string[] };
type Action = { type: "next" } | { type: "answer"; value: string };
// React calls init(initialArg) once, on the first render, to build the state.
function createState(questionCount: number): State {
return { step: 0, answers: new Array<string>(questionCount).fill("") };
}
function reducer(state: State, action: Action): State {
switch (action.type) {
case "next":
return { ...state, step: Math.min(state.step + 1, state.answers.length - 1) };
case "answer": {
const answers = [...state.answers]; // copy first: never mutate state in a reducer
answers[state.step] = action.value;
return { ...state, answers };
}
}
}
export default function Wizard({ questionCount }: { questionCount: number }) {
// Passing the argument plus the builder. Writing useReducer(reducer, createState(questionCount))
// instead also works, but it rebuilds that object on every render and throws it away.
const [state, dispatch] = useReducer(reducer, questionCount, createState);
return (
<View style={styles.screen}>
<Text>{`Question ${state.step + 1} of ${state.answers.length}`}</Text>
<Pressable onPress={() => dispatch({ type: "next" })} style={styles.button}>
<Text>Next</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 16 },
button: { paddingHorizontal: 20, paddingVertical: 12, borderRadius: 8, backgroundColor: "#e5e5e5" },
});A reducer is a plain function, so it is testable
The reducer exercised directly, three actions in a row. This is the practical reason to move form logic into a reducer: the rules can be tested as data in and data out, with no rendering and no native runtime, which keeps the expensive component tests for things that actually draw.
type State = { email: string; error: string | null };
type Action = { type: "field"; value: string } | { type: "submit" };
export function reducer(state: State, action: Action): State {
switch (action.type) {
case "field":
return { ...state, email: action.value, error: null };
case "submit":
return state.email.includes("@")
? { ...state, error: null }
: { ...state, error: "Enter a valid email" };
}
}
// No component, no renderer, no device: call it with a state and an action.
const typed = reducer({ email: "", error: null }, { type: "field", value: "someone" });
console.log(typed.email);
// Output: someone
const submitted = reducer(typed, { type: "submit" });
console.log(submitted.error);
// Output: Enter a valid email
const fixed = reducer(submitted, { type: "field", value: "[email protected]" });
console.log(fixed.error);
// Output: null
Discussion