React / Performance and trustworthy boundaries / Lesson 18
Component APIs and custom Hooks
Design small reusable contracts that preserve ownership, accessibility, and the Rules of Hooks as implementation details evolve.
On this page
Lesson goals
- An API includes behavior and ownership
- Expose intent through a controlled component
- Evolve the public contract with evidence
An API includes behavior and ownership
A reusable component’s API specifies accepted inputs, defaults, callbacks, rendered semantics, state ownership, and reset behavior. A dozen Boolean flags can permit contradictory modes and make testing combinatorial. Prefer a coherent responsibility with explicit variants or composed children when those clarify the use cases. Do not expose every internal state variable merely to make a component configurable. Consumers should understand what happens when a value changes, a callback is absent, or a subtree remounts. Compatibility includes event and focus behavior as well as prop names, so an apparently cosmetic refactor can still break a documented interaction contract.
Choose controlled and uncontrolled modes deliberately
A controlled reusable control receives its value and an onChange-style callback from the owner. An uncontrolled version owns initial local state, often from a defaultValue-style prop. Supporting both can be legitimate, but define initialization, mode switching policy, and callback semantics carefully rather than combining two competing sources of truth. A prop value should not be copied into state and kept synchronized by an Effect without a real requirement. If a new record should reset local state, a deliberate identity boundary may be clearer. Keep domain constraints near the owner that can enforce them across all related controls.
Expose intent through a controlled component
The first example defines a Stepper that requests an increment through onIncrement while its parent owns the count. The child does not create a duplicate counter or mutate a prop. Its button is native and has an explicit type, and the visible label communicates the current value. A richer API could accept bounds or disabled state, but those should be added only with documented semantics and tests. Avoid a callback that ambiguously means both “user requested change” and “server confirmed save.” Separate intent from confirmed outcomes so consumers do not infer effects that the component never performed.
import { useState } from "react";
function Stepper({ value, onIncrement }) { return <button type="button" onClick={onIncrement}>Increase from {value}</button>; }
export default function App() { const [count, setCount] = useState(0); return <main><h1>Controlled API</h1><Stepper value={count} onIncrement={() => setCount(c => c + 1)} /></main>; }
Initial button: Increase from 0. After click: Increase from 1.
The parent is the single state owner and the child reports intent through its callback.
Custom Hooks share logic, not state instances
The second example extracts useCounter, then calls it independently in two Counter components. Each call receives its own state because a Hook shares a reusable logic pattern rather than one global instance. Ordinary state and Effect Hooks must be called in a consistent top-level order in components or custom Hooks, before early returns that would skip them. Do not call them in event callbacks, loops, or ordinary utility functions. React’s special use API has its own documented exceptions in supported versions; those do not relax the rules for useState or useEffect. Keep custom Hook names meaningful and ensure their internal dependencies and cleanup remain correct.
import { useState } from "react";
function useCounter() { const [count, setCount] = useState(0); return { count, increment: () => setCount(c => c + 1) }; }
function Counter({ label }) { const { count, increment } = useCounter(); return <button type="button" onClick={increment}>{label}: {count}</button>; }
export default function App() { return <main><h1>Independent Hook calls</h1><Counter label="A" /><Counter label="B" /></main>; }
Initial: A: 0 and B: 0. Clicking A changes only A to 1.
Each component calls the same reusable Hook logic but owns a distinct state instance.
Evolve the public contract with evidence
Test a reusable API through representative consumers, including longer labels, missing optional callbacks, disabled behavior, nested providers, and deliberate identity changes. Document whether a callback is synchronous notification or an owned async action, and how errors propagate. Keep imperative escape hatches narrow and version-sensitive ref patterns explicit. A custom Hook should not conceal a network request, storage write, or global subscription whose lifecycle callers cannot understand. Review changes for accessible names, focus, and server/client assumptions. Reuse is valuable when it reduces duplicated reasoning while preserving clear boundaries, not when one abstraction accumulates every feature into an untestable control panel.
Common errors and fixes
A custom Hook unexpectedly shares mutable global data
- Why it happens
- Reusable logic was implemented with a module-level mutable singleton.
- How to fix it
- Use scoped state or an explicit external-store contract.
- Prevent it next time
- Test two independent consumers.
Hook order changes after an early return
- Why it happens
- An ordinary Hook is skipped on one render path.
- How to fix it
- Move Hooks before conditional exits or extract a component.
- Prevent it next time
- Keep Rules-of-Hooks linting enabled.
A reusable control has competing values
- Why it happens
- Props and local state both claim authority.
- How to fix it
- Define controlled or uncontrolled ownership explicitly.
- Prevent it next time
- Test initialization and updates by the documented owner.
Check your understanding
1. Why does extracting useCounter not make two counters share their numbers?
Show worked solution
Each Hook invocation belongs to its component instance and position in the tree. The function reuses the transition logic, not the state storage. To share one count, lift it to a common owner or use an explicitly scoped shared-state integration and pass the relevant contract down.
2. What should be documented before adding an async onSave prop to a form component?
Show worked solution
Specify who owns pending state, whether the callback returns a promise, which failures reject, how duplicates and cancellation behave, and what success confirms. Keep validation and authorization boundaries explicit. A callback named onSave alone cannot establish those lifecycle and trust guarantees.
Key takeaways
- Reusable APIs include ownership and semantics.
- Custom Hooks share logic with independent state.
- Ordinary Hooks require consistent call order.