skip to content

When designing what a custom React hook returns, when should you return an array (a tuple, the way useState does) and when should you return an object?

level: middleimportance: should knowfreq 52%

answer

  1. shape follows call-site ergonomics
  2. tuples rename for free
  3. objects survive new fields
  4. order is a contract you cannot change
  5. as const keeps a TypeScript tuple

basics

~20 s

Return a tuple for two or three positional values callers will frequently rename, as useState does. Return an object once there are several values or the set may grow, because names document meaning and adding a field breaks no existing caller.

solid answer

~50 s

I use the tuple shape only when the hook returns a small, obviously ordered pair that callers rename constantly — that is why `useState` returns `[value, setValue]`: you can call it `name`/`setName` at one call site and `email`/`setEmail` at the next with no renaming syntax, and you can call the hook twice in one component without collisions. Once there are more than two or three values, or the list is likely to grow, I return an object. Names are self-documenting at the call site, callers destructure only what they need, ordering mistakes become impossible, and adding a field later is backwards-compatible. React's own APIs follow the same rule: `useActionState` returns a tuple, `useFormStatus` returns an object with `pending`, `data`, `method` and `action`. In TypeScript I add `as const` to a tuple return so the type is a fixed-length tuple rather than a union array.

code

typescript · 16 lines
typescript
import { useCallback, useState } from 'react';

// Tuple: a small canonical pair the caller renames at will.
export function useCounter(initial = 0) {
  const [count, setCount] = useState(initial);
  const increment = useCallback(() => setCount((c) => c + 1), []);
  return [count, increment] as const;
}

// Object: several named values, and adding one later breaks no caller.
export function useOrderDraft() {
  const [orders, setOrders] = useState<string[]>([]);
  const add = useCallback((id: string) => setOrders((prev) => [...prev, id]), []);
  const reset = useCallback(() => setOrders([]), []);
  return { orders, add, reset };
}

go deeper

for a junior

Know the two shapes and the default: a pair like useState returns can be an array, and anything with several named pieces should be an object so callers destructure the fields they want.

for a middle

Explain the trade-off in terms of call sites — tuples rename for free but make order the contract, objects self-document and stay backwards compatible as fields are added — and cite useState versus useFormStatus as the precedent.

for a senior

Go beyond shape: talk about what should be exposed at all, not returning values callers can derive, which callbacks you promise to keep identity-stable, and how options objects beat a growing list of positional arguments.

for a principal

Treat hook return shapes as versioned API surface in a shared codebase: what a change to one costs across teams, how to add fields without breaking callers, and where a convention document beats case-by-case taste.

## The two shapes and what they optimise for A custom hook's return value is its public API, and there are really only three shapes: a single value, a positional tuple, or a named object. Each optimises for something different. **Tuple — optimises for renaming.** `const [count, setCount] = useState(0)` works because array destructuring binds by position, so every call site chooses its own names for free. That matters enormously for a hook you call several times in one component: `[name, setName]` and `[email, setEmail]` coexist with no ceremony. The price is that position is the contract. Add a third element in the middle and every caller silently reads the wrong thing; skip an element and you write the awkward `const [, setValue] = …`. **Object — optimises for names and growth.** `const { data, error, refetch } = useOrders()` documents itself, lets each caller destructure only what it uses, and can gain a fourth field tomorrow without touching a single call site, because destructuring ignores properties it does not name. The price is renaming friction (`const { data: orders } = …`) and, when two instances are used in one component, the need to rename at every call site. **Single value.** If a hook has exactly one thing to give — a context value, a media-query boolean — return it bare. Wrapping one value in an object or a one-element array is pure noise. ## A practical rule Use a tuple when *all* of these hold: there are at most two or three values; their order is obvious and canonical (state first, updater second); callers will routinely rename them; and the set will not grow. Otherwise use an object. "Will not grow" is the clause people underestimate — most hooks that start as `[data, refetch]` end up wanting `isLoading` and `error`, and by then the tuple is a liability. React's own surface follows exactly this split: `useState` and `useActionState` return tuples; `useFormStatus` returns an object. ```typescript export function useCounter(initial = 0) { const [count, setCount] = useState(initial); const increment = useCallback(() => setCount((c) => c + 1), []); return [count, increment] as const; // renamable pair } ``` The `as const` is not cosmetic. Without it TypeScript infers `(number | (() => void))[]` — an array of a union — and destructuring gives you useless types. With it you get a fixed two-element tuple with the right type per position. The alternative is an explicit return type annotation such as `[number, () => void]`. ## Design decisions that outrank the shape The shape question is easy; these matter more: **Return values, not instructions.** A hook should hand back what the component renders and the functions it calls, not internal machinery. Exposing raw setters plus derived flags plus refs invites callers to depend on details you wanted to keep changeable. **Do not return what the caller can compute.** `items` and `itemCount` and `isEmpty` triple the surface for no gain; the caller writes `items.length === 0`. Return derived fields only where the derivation is non-obvious or genuinely expensive. **Be deliberate about function identity.** Callbacks in the return value end up in dependency arrays and memo comparisons downstream, so decide which of them are stable across renders and say so in the hook's documentation. **Object identity churns by default.** Returning a fresh object literal every render is normally fine, because callers destructure it immediately and never compare it. It only matters if a caller passes the whole returned object down as a prop to a memoized child — then wrap the return value in `useMemo`, or better, tell callers to pass the individual fields. **Arguments deserve the same care.** A hook taking four positional parameters is as fragile as a four-element tuple return; take one options object instead, and treat a growing pile of boolean flags as evidence that two different hooks are hiding inside one. ## The answer to give Say that the shape follows the call-site ergonomics: tuple for a small renamable pair whose order is canonical, object for anything named, optional or growable, bare value when there is only one. Then show the judgement — name React's own examples, mention `as const` for TypeScript tuples, and note that the harder API questions are which values to expose at all and which callbacks are identity-stable.

  • Why does useState return an array rather than an object with value and setValue?
    Because array destructuring binds by position, so every call site picks its own names with no renaming syntax — `[name, setName]`, `[email, setEmail]` — and a component can call `useState` many times without collisions. An object would force `const { value: name, setValue: setName } = …` at every single call site, which is far worse ergonomics for the most-used hook in React.
  • A hook returns { data, error, refetch } and you want to add isStale. What breaks for existing callers?
    Nothing. Object destructuring only binds the properties a caller names, so existing code ignores the new field entirely. That backwards compatibility is the main reason to prefer objects once a return set is likely to grow — the same addition to a tuple would either land at the end, where it is easy to miss, or in the middle, where it silently shifts every caller's bindings.
  • Should a custom hook return a memoized object so its identity is stable?
    Usually not. Callers destructure the returned object on the spot and never compare it, so a fresh literal each render costs nothing. It only matters if a caller passes the whole object as a prop to a memoized child or lists it in a dependency array — and the better fix there is to pass the individual fields. Reserve `useMemo` on the return value for hooks where you have measured that pattern in real callers.

saying these in an interview costs you the question

  • Returns a five-element tuple and expects callers to remember the order
  • Thinks arrays destructure faster than objects, so tuples are quicker
  • Says React requires custom hooks to return an array
  • Forgets as const, then wonders why the tuple types are a union
  • Returns every internal ref and setter, freezing the implementation into the API

context