Typing a custom hook that returns a useState-style tuple
useToggle must hand callers the same shape useState does — a two-slot tuple whose first element is a boolean and whose second is a zero-argument toggle function.
Constraint: const [on, toggle] = useToggle() must give on: boolean and toggle: () => void, never boolean | (() => void) in both slots. No as assertion at the call site, no any.
import { useCallback, useState } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
// your code here
}
Write the implementation.
Pin the tuple with as const — return [on, toggle] as const — or annotate the return type as [boolean, () => void]. Without one of the two, TypeScript widens the array literal to (boolean | (() => void))[], and destructuring hands the caller that union in both slots.
- ✗Expecting
return [on, toggle]to infer a tuple instead of widening to an array - ✗Reaching for a call-site assertion instead of fixing the hook's own return type
- ✗Believing
as constmakes the returned function itself unusable as a callback
- →Why does
useStateitself return a tuple rather than an array of a union? - →When is returning a named object preferable to a tuple from a custom hook?
The solution
import { useCallback, useState } from 'react';
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const;
// ^? readonly [boolean, () => void]
}
const [on, toggle] = useToggle();
// ^boolean ^() => void
Why it breaks without as const
return [on, toggle] is a mutable array literal. TypeScript widens it to (boolean | (() => void))[]: the length is forgotten and so are the positions. Destructuring then produces the union in both slots — toggle() no longer calls and if (on) no longer narrows.
as const produces readonly [boolean, () => void], keeping both length and positions. The equivalent without it is an explicit return annotation:
export function useToggle(initial = false): [boolean, () => void] {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle];
}
The only difference is readonly: the as const form forbids the caller from assigning into the tuple's slots, the annotation allows it. as const does nothing to the toggle function itself — it fixes the array's type, it does not freeze the values inside it.