The seat counter on the subscription page had one job: a minus, a plus and the monthly price, one seat at a time. Then sales signed the first enterprise contract, sold in packs of five with ten seats at least, and asked for a step option. The partner plan wanted its five free seats skipped. Within a quarter the hook had seven options, and a change for one plan broke the counter on another.
Each option was a rule the hook’s author had to foresee, write and keep working beside every other rule. The plans weren’t asking for a cleverer counter. They were asking for the last word on what one press does.
A state reducer gives them that word. The hook works out the change it would make and hands it to a reducer the caller passes in, which returns the state that actually renders. Its partner, the prop getter, is a function that returns the props an element needs, merged with the caller’s own. Together they invert control: the hook keeps the mechanics, and the code that uses it decides the rules and the markup.
Every plan’s rule became an option
Here are the hook’s options after three teams had asked for something:
// Avoid: one option for each team's rule
interface UseSeatsOptions {
initial: number;
min?: number;
max?: number;
/** Enterprise contracts sell seats in packs. */
step?: number;
/** Removing goes in packs too, but only above the contract. */
removeStep?: number;
contractMinimum?: number;
/** Partner plans: the first five seats are free, so they skip. */
skipFrom?: number;
}Each line looks reasonable on its own. Together they interact. removeStep applies only above contractMinimum, skipFrom has to agree with step, and every combination is a case the hook must handle and test. Its author has become the reviewer of every plan’s pricing rules, and the next plan will bring an option nobody has thought of yet.
The markup had the same problem in miniature. The hook returned an onAdd handler, and the plan page that needed to log each press wrote its own button around it. That button left out the limit, so the plus looked live at fifty seats and did nothing when pressed.
The hook proposes, the caller decides
Split each press into two steps. The hook works out what it would do, with its own simple rule: one seat. Then it asks the caller, through a function shaped like a useReducer reducer that also receives the hook’s proposed changes. Whatever that function returns is the next state, held within the limits the hook keeps.
The hook proposes; the caller decides. A caller with no opinion passes no reducer and gets the default. A caller with a contract changes one thing, the step, and keeps everything else the hook already does. The prop getters apply the same idea to the elements: the caller passes the props it wants, and the getter returns them with the hook’s wiring added, so neither side can drop the other’s.
Building a seat counter that takes advice
The hook lives in use-seats.tsx, shown in three fragments, and two plan pages use it.
Actions and a reducer type the caller can read
export type SeatsAction = { type: "add" } | { type: "remove" };
export interface SeatsState {
seats: number;
/** Changes so far: the status line keys its message on it. */
edits: number;
}
/** The hook's own rule: one seat at a time. */
function step(state: SeatsState, action: SeatsAction): SeatsState {
const seats = state.seats + (action.type === "add" ? 1 : -1);
return { seats, edits: state.edits + 1 };
}
/** The caller's last word on each change the hook proposes. */
export type SeatsReducer = (
state: SeatsState,
action: SeatsAction & { changes: SeatsState },
) => SeatsState;The action types are now public. A caller’s reducer switches on "add" and "remove", so renaming either is a breaking change. edits counts the changes so the status line can key on it.
A reducer that asks the caller
interface UseSeatsOptions {
initial: number;
min?: number;
max?: number;
stateReducer?: SeatsReducer;
}
export function useSeats(options: UseSeatsOptions) {
const { initial, min = 1, max = Infinity, stateReducer } = options;
const [state, dispatch] = useReducer(
(current: SeatsState, action: SeatsAction) => {
const changes = step(current, action);
const next = stateReducer
? stateReducer(current, { ...action, changes })
: changes;
// The caller has the last word, within the limits the hook
// keeps.
const seats = Math.min(max, Math.max(min, next.seats));
return seats === next.seats ? next : { ...next, seats };
},
{ seats: initial, edits: 0 },
);The reducer passed to useReducer runs the hook’s rule first and then hands the result to stateReducer, if there is one. The caller sees the current state, the action and the proposed changes, and returns what should render. The hook clamps that answer to min and max, so the limits are the one rule no caller can talk it out of.
Getters that merge instead of overwrite
// The caller's props go in and the wiring comes out. Handlers
// chain, the caller's first, and a caller that calls
// preventDefault() keeps the hook out.
function getStepProps(
type: SeatsAction["type"],
props: ComponentProps<"button">,
) {
const atLimit =
type === "add" ? state.seats >= max : state.seats <= min;
return {
...props,
type: "button" as const,
"aria-disabled": atLimit || undefined,
onClick(event: MouseEvent<HTMLButtonElement>) {
props.onClick?.(event);
if (!event.defaultPrevented && !atLimit) dispatch({ type });
},
};
}
return {
...state,
getAddProps: (props: ComponentProps<"button"> = {}) =>
getStepProps("add", props),
getRemoveProps: (props: ComponentProps<"button"> = {}) =>
getStepProps("remove", props),
};Look at the order inside getStepProps. The caller’s props are spread first, then the hook sets type and aria-disabled, so a caller can add a label or a class but can’t turn the button into a submit button or hide its limit. The click handlers chain: the caller’s runs first, and a caller that calls preventDefault() keeps the hook out of that press.
The Team plan, one seat at a time
"use client";
// The subscription page's seats: the page's own markup around
// useSeats.
import { useSeats } from "./use-seats";
import type { SeatsReducer } from "./use-seats";
const PRICE_PER_SEAT = 29;
interface SeatPickerProps {
initial: number;
min?: number;
max?: number;
stateReducer?: SeatsReducer;
/** What one press does, for the buttons' names. */
addLabel?: string;
removeLabel?: string;
}
export function SeatPicker(props: SeatPickerProps) {
const {
addLabel = "Add a seat",
removeLabel = "Remove a seat",
...options
} = props;
const { seats, edits, getAddProps, getRemoveProps } =
useSeats(options);
const price = seats * PRICE_PER_SEAT;
const summary = `${seats} seats, £${price} a month`;
return (
<fieldset className="seats">
<legend>Seats</legend>
<button {...getRemoveProps({ "aria-label": removeLabel })}>
−
</button>
<span aria-hidden="true">{seats}</span>
<button {...getAddProps({ "aria-label": addLabel })}>+</button>
<p>{summary}</p>
{/* Mounted empty; each change is a new line, read again. */}
<p role="status" className="visually-hidden">
{edits > 0 ? <span key={edits}>{summary}</span> : null}
</p>
</fieldset>
);
}The page owns every element: the fieldset, the buttons’ labels, the price. The getters supply the behaviour. At the limit the plus button stays focusable and marked aria-disabled, and a press does nothing. The status line starts empty and gets a new keyed line on each change, so a screen reader announces “20 seats, £580 a month” every time, even when the same total comes round again.
The enterprise plan, in packs of five
"use client";
// Enterprise contracts: seats in packs of five, from ten to fifty.
// The limits stay options, so the hook keeps them; the step is the
// reducer's.
import { SeatPicker } from "./seat-picker";
import type { SeatsReducer } from "./use-seats";
export const inPacksOfFive: SeatsReducer = (
state,
{ type, changes },
) => {
const seats = state.seats + (type === "add" ? 5 : -5);
return { ...changes, seats };
};
export function ContractSeats() {
return (
<SeatPicker
initial={10}
min={10}
max={50}
stateReducer={inPacksOfFive}
addLabel="Add five seats"
removeLabel="Remove five seats"
/>
);
}A two-line reducer and a min of ten replace the step, removeStep and contractMinimum options. Follow three presses from ten seats:
| Press | state.seats | changes.seats | Renders |
|---|---|---|---|
| Add five seats | 10 | 11 | 15 |
| Remove five seats | 15 | 14 | 10 |
| Remove five seats | 10 | none: the button is at its limit | 10 |
The hook proposed 11 and the contract said 15. At ten seats the minus button is marked at its limit before any reducer runs, because the limits stayed options the hook keeps.
What the last word costs
The caller gains the rules; the hook keeps only the guarantees it checks itself.
A reducer can break the hook’s rules
A state reducer can return anything. Start a picker with inPacksOfFive at 47 seats and one press asks for 52 on a contract capped at 50. Unchecked, the hook’s limits would be advice the caller may ignore, so the hook enforces them after the reducer answers, and the press lands on 50. Keep each rule the hook must guarantee as an option it checks on the way out, and leave only the rules that vary to the reducer. The action types and the state’s shape also become public API, documented and versioned like props.
Fresh props on every render
Each call to a getter builds a new object and a new onClick, so a child wrapped in memo re-renders anyway. For two buttons that doesn’t matter. For a long list of options, each spread with a getter, make the list itself the memoised component rather than each row. The reducers do a little integer arithmetic per press.
The getters carry the accessibility
The hook decides the attributes that keep the counter usable, and the caller can’t drop them by accident. Every button is type="button", so a press inside a form never submits it. A button at its limit keeps its focus and says it is unavailable with aria-disabled, where disabled would push focus away mid-press. The page adds the names and the status line, which mounts empty and announces each change as a new line.
Testing the rules, not the reducer
The tests render the real pages and press the buttons by name, so they check what a person sees rather than what the reducer returns:
import { render, screen, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { expect, test } from "vitest";
import { ContractSeats, inPacksOfFive } from "./contract-seats";
import { SeatPicker } from "./seat-picker";
import { useSeats } from "./use-seats";
test("adds one seat at a time and stops at the limit", async () => {
const user = userEvent.setup();
render(<SeatPicker initial={19} max={20} />);
const status = screen.getByRole("status");
expect(status).toBeEmptyDOMElement();
const add = screen.getByRole("button", { name: "Add a seat" });
await user.click(add);
expect(status).toHaveTextContent("20 seats, £580 a month");
expect(add).toHaveAttribute("aria-disabled", "true");
await user.click(add);
expect(add).toHaveFocus();
expect(status).toHaveTextContent("20 seats, £580 a month");
});
test("a repeated summary is a new line, read again", async () => {
const user = userEvent.setup();
render(<SeatPicker initial={19} max={20} />);
const status = screen.getByRole("status");
await user.click(
screen.getByRole("button", { name: "Add a seat" }),
);
const first = within(status).getByText("20 seats, £580 a month");
await user.click(
screen.getByRole("button", { name: "Remove a seat" }),
);
await user.click(
screen.getByRole("button", { name: "Add a seat" }),
);
expect(within(status).getByText("20 seats, £580 a month")).not.toBe(
first,
);
});
test("a contract moves in packs of five from ten", async () => {
const user = userEvent.setup();
render(<ContractSeats />);
const remove = screen.getByRole("button", {
name: "Remove five seats",
});
expect(remove).toHaveAttribute("aria-disabled", "true");
await user.click(
screen.getByRole("button", { name: "Add five seats" }),
);
expect(screen.getByRole("status")).toHaveTextContent(
"15 seats, £435 a month",
);
expect(remove).not.toHaveAttribute("aria-disabled");
await user.click(remove);
expect(screen.getByRole("status")).toHaveTextContent(
"10 seats, £290 a month",
);
});
function GuardedSeats({ approved }: { approved: boolean }) {
const { seats, getAddProps } = useSeats({ initial: 5 });
const guard = {
onClick: (event: { preventDefault(): void }) =>
approved || event.preventDefault(),
};
return (
<button
{...getAddProps({
...guard,
"aria-label": `Add a seat, ${seats} now`,
})}
>
+
</button>
);
}
test("the caller's handler runs first and can opt out", async () => {
const user = userEvent.setup();
const { rerender } = render(<GuardedSeats approved={false} />);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveAccessibleName(
"Add a seat, 5 now",
);
rerender(<GuardedSeats approved />);
await user.click(screen.getByRole("button"));
expect(screen.getByRole("button")).toHaveAccessibleName(
"Add a seat, 6 now",
);
});
test("the limits hold on the reducer's answer", async () => {
const user = userEvent.setup();
render(
<SeatPicker
initial={47}
max={50}
stateReducer={inPacksOfFive}
addLabel="Add five seats"
/>,
);
await user.click(
screen.getByRole("button", { name: "Add five seats" }),
);
expect(screen.getByRole("status")).toHaveTextContent(
"50 seats, £1450 a month",
);
});All five pass. The third replays the enterprise contract, the fourth shows a caller’s handler keeping the hook out of a press until the change is approved, and the fifth holds a pack of five at the contract’s maximum.
Client code, configured from the client
useSeats holds state, so seat-picker.tsx and contract-seats.tsx open with "use client". A reducer is a function and can’t come from a Server Component as a prop. ContractSeats imports it on the client, and the server picks ContractSeats or SeatPicker by data, such as the plan’s id.
React 19 and the Compiler
Nothing here changed in React 19. If a page spreads a getter onto its own Button component rather than a <button>, React 19 hands that component ref as a prop, so a ref passed through the getter arrives without forwardRef. The React Compiler may memoise the getters’ results in the page that calls them; the pattern works the same without it.
When an option or a controlled value is enough
A state reducer earns its place when callers disagree about the rules and the disagreements can’t be listed in advance. If every plan wanted a different step and nothing else, a step option is clearer than a reducer, and it keeps the limits honest.
Often the caller only needs the final value. Make the component controlled and the parent sees each new value before storing it, which is the last word at the grain of a value instead of an action. Prop getters, likewise, matter when callers render their own elements. A component that renders its own buttons needs none.
How Turtlekit’s Slider gives the page the last word
Turtlekit’s components take the controlled road rather than exposing reducers: the rules that vary most often become documented props, and the page owns the value. Slider already knows about steps, limits and how a value reads, so the enterprise contract is configuration:
// Contract seats with Turtlekit: the packs are Slider's step, the
// limits its min and max, and the page owns the value, so it has the
// last word.
import { useState } from "react";
import { Slider } from "turtlekit";
const PRICE_PER_SEAT = 29;
export function ContractSeats({
onChange,
}: {
onChange: (seats: number) => void;
}) {
const [seats, setSeats] = useState(10);
return (
<Slider
label="Seats"
min={10}
max={50}
step={5}
value={seats}
onValueChange={(next) => {
setSeats(next);
onChange(next);
}}
formatValue={(n) =>
`${n} seats, £${n * PRICE_PER_SEAT} a month`
}
ends={["10 seats", "50 seats"]}
/>
);
}The packs are step, the contract is min and max, and formatValue gives the readout and the screen reader the same “15 seats, £435 a month”. Every change passes through the page’s onValueChange before it shows, so the page still has the last word. Underneath is a native range input, so the keyboard works as people expect.
When the wiring goes missing
The caller’s onClick never runs
The handler sits beside the getter instead of inside it. In <button onClick={log} {...getAddProps()}> the getter’s onClick comes later and replaces it. Pass it in: getAddProps({ onClick: log }), and the getter chains both.
The status line goes quiet once a reducer is passed
The reducer builds its answer from state and ignores changes, so edits never moves and no new keyed line renders. Start from changes, as inPacksOfFive does, so the fields the hook maintains stay right.
Pitfall
A state reducer must be pure. In development, Strict Mode calls reducers twice, so a reducer that logs each press to analytics logs it twice there. Put side effects in the caller’s onClick, passed through the getter, and keep the reducer to arithmetic.
Keep the mechanics, give away the rules
The seven-option hook failed because it tried to know every plan’s pricing. The finished hook knows less: how to count, where the limits are and which attributes keep its buttons usable. Everything a plan disagrees about goes through a reducer the plan writes and props the plan passes. Two lines made a contract of packs of five, and the hook’s author never had to hear about it.