Skip to content
Get the beta

Search the docs

Start with the beta
Installation
Build your first page
Thinking in Turtlekit
Apps, websites and embeds
Components or native elements
Colour
Typography
Layout
Spacing, icons and shape
Components
Root
Layout
Typography
Code block
Button
Icon button
Button group
Call to action
Toolbar
Field
Text field
Text area
Select
Combobox
Date input
Save bar
Newsletter signup
File upload
Calendar
One-time code
Checkbox
Radio group
Switch
Segmented control
Toggle
Slider
Badge
Card
Data list
Item
Icon tile
Num
Time
Metric
Accordion
FAQ
Avatar
Media
Scroll area
Resizable
Carousel
Image slider
Logo cloud
Testimonials
Table
Data table
Chart
Site header
Site footer
Tabs
Page header
Breadcrumb
Pagination
Sidebar
Navigation menu
Callout
Error summary
Toast
Empty state
Skeleton
Progress
Spinner
Tooltip
Dialog
Popover
Hover card
Dropdown menu
Context menu
Menubar
Command
Elements area
Visually hidden
Error boundary
Design rules
Staying within the kit
Layout
Icons
Images and media
Typography
Readability
Colour
Composition
Accessibility
Elements
Motion
Interaction
AI pitfalls
Pricing
React design patterns
Single responsibility in React components
Presentational and container components in React
Children, function children and render props in React
The variant pattern in React
Compound components in React
Higher-order components in React
State colocation in React
Lifting state up in React
Deriving state during render
One status instead of many booleans
Adjusting state when a prop changes
Reading context with use() in React 19
Context selectors in React
Custom hooks and hook composition in React
One effect per concern in React
The latest ref pattern in React
Effect Events with useEffectEvent in React
Detecting the first render in React
Exposing methods with useImperativeHandle in React
Debouncing input in React
useDeferredValue in React
Loading components on demand in React
Virtualising long lists in React
Guard clauses and early returns in React
Skeleton placeholders in React
Rendering partial data in React
Empty states in React
Error boundaries in React
Reading async data with use() and Suspense in React
useTransition and Actions in React
Optimistic UI updates with useOptimistic
useActionState and form Actions in React
useSyncExternalStore in React
Ref as a prop and ref cleanup in React 19
Accessible ids with useId in React
Portals in React
Roving focus and keyboard navigation in React
Controlled and uncontrolled components in React
The state reducer and prop getters in React
Headless components and the provider pattern in React
The polymorphic as prop in React

Appearance

These change every page and preview on this site.

The state reducer and prop getters in React

Turtlekit Editorial Team

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 state reducerstep()changesstateReducerlast wordstaterendersa prop getterpropsthe caller’sgetAddPropsmerges<button>spreads them
Each press becomes an action: step() proposes the changes, and the caller’s stateReducer returns the state that renders. The getter takes the caller’s props and hands back the button’s, with both handlers chained.

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:

ContractSeats, press by press
Pressstate.seatschanges.seatsRenders
Add five seats101115
Remove five seats151410
Remove five seats10none: the button is at its limit10

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.

Controlled and uncontrolled components in React

When the caller only needs the final value, not a say over each change.

Custom hooks and hook composition in React

When the logic is worth sharing but every caller follows the same rules.

Compound components in React

When callers need to arrange the parts, and the rules can stay fixed.

One status instead of many booleans

When the state the reducer returns starts growing flags that can disagree.