Build a custom React hook that steps through a fixed list of values, one at a time. useCycle('red', 'green', 'blue') hands back the current value (starting with the first) and a cycle function. Each call to cycle() moves to the next value, looping back to the start after the last — like clicking a toggle that rotates through a set of options. You can also pass an index to jump straight to a specific value. It's modeled on framer-motion's hook of the same name.
function useCycle<T>(...items: T[]): [current: T, cycle: (index?: number) => void];
current starts as items[0]. cycle() advances to the next item; cycle(index) jumps directly to items[index]. The cycle function keeps the same identity across every render.
function Theme() {
const [color, cycle] = useCycle('red', 'green', 'blue');
return <button onClick={() => cycle()} style={{ color }}>{color}</button>;
}
// renders 'red'; click → 'green'; click → 'blue'; click → wraps to 'red'
// Advancing wraps around; passing an index jumps directly.
const [value, cycle] = useCycle('a', 'b', 'c'); // value === 'a'
cycle(); // value === 'b'
cycle(); // value === 'c'
cycle(); // value === 'a' (wrapped past the end)
cycle(2); // value === 'c' (jumped to items[2])
cycle() call, current is items[0].cycle() on the last item returns to the first; the list is a loop, not a dead end.cycle accepts an optional index. cycle() steps forward by one; cycle(2) jumps straight to items[2].cycle must have a stable identity. Returning a brand-new function each render is not acceptable here — the same reference must come back across renders, so it's safe to pass to memoized children or effect dependency arrays.You'll hold a position in a fixed list with useState, then hand back the item at that position plus one stable function that moves the position forward or jumps it directly.
Lots of controls rotate through a small set of options: a button that flips between light, dark, and system themes; a status pill that steps "todo → doing → done"; an image carousel that loops. Each is the same shape — a list of values and a "next, please" action that wraps back to the start when it runs off the end. useCycle packages that: give it the values, and it returns the one you're on plus a cycle function that advances to the next, looping around, or jumps straight to an index you name.
Think of the list as a loop and keep a single number — the index — pointing at the current spot. The value you return is just items[index]. Advancing means adding one to the index; wrapping means taking that sum modulo the list length, so after the last index you land back on 0. Jumping is even simpler: set the index to the number you were handed. The list itself never changes — only the pointer moves.
The obvious version reads the current index and sets it to one more:
const { useState } = require('react');
function useCycle(...items) {
const [index, setIndex] = useState(0);
const cycle = (next) => {
if (typeof next === 'number') {
setIndex(next);
} else {
setIndex(index + 1); // advance by one
}
};
return [items[index], cycle];
}
Two things break. First, setIndex(index + 1) never wraps — once index reaches the last slot, the next click sets it past the end and items[index] is undefined. Second, and more subtly, index is a snapshot frozen when the function rendered. If a handler calls cycle() twice in the same event, both closures read the same stale index of 0 and both call setIndex(1); React batches them, the last write wins, and you advance once instead of twice. On top of that, a fresh cycle function is created every render, so its identity changes — passing it to a memoized child or an effect dependency array would misbehave.
const { useState, useCallback } = require('react');
function useCycle(...items) {
const [index, setIndex] = useState(0);
// useCallback with an empty dependency array gives `cycle` ONE identity that
// never changes across renders — safe to pass to memoized children or effect
// deps. The empty deps are sound because every read below uses the functional
// updater form, so the callback never closes over a stale `index`.
const cycle = useCallback(
(next) => {
if (typeof next === 'number') {
// Jump straight to an index. The modulo keeps it inside the list even
// if a caller passes a number past the end.
setIndex(((next % items.length) + items.length) % items.length);
} else {
// Advance by one. The functional updater reads the index React is about
// to apply, so two calls in one batch stack (0 → 1 → 2) instead of both
// reading a stale 0. Modulo wraps the last item back to the first.
setIndex((i) => (i + 1) % items.length);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return [items[index], cycle];
}
module.exports = { useCycle };
Three shifts from the naive version. The advance now uses the functional updater (i) => (i + 1) % items.length, which both wraps with modulo and reads the freshest pending index instead of a frozen snapshot. The jump wraps too, with an extra + items.length so even a negative index lands in range. And cycle is wrapped in useCallback([]), pinning its identity across renders — which is only safe because the updater form means the callback no longer depends on index.
Start with useCycle('red', 'green', 'blue'). The first render calls useState(0), so index is 0 and current is items[0] — 'red'. Now a handler fires cycle() twice in one event:
setIndex((i) => (i + 1) % 3) is queued. React will call it with the latest pending index. Pending starts at 0, so it produces (0 + 1) % 3 = 1.cycle() queues another updater behind it. React calls it with 1 (the pending index after step 1), producing (1 + 1) % 3 = 2.React applies the batch and re-renders once. On that render index is 2, so current is items[2] — 'blue'. Now suppose a later click calls cycle() from index 2: the updater produces (2 + 1) % 3 = 0, wrapping back to 'red'. Had the advance been setIndex(index + 1), both calls in the first batch would have used the render-time index of 0 and the result would have been 'green', not 'blue'.
setIndex(index + 1) runs past the last slot, and items[index] becomes undefined. Fix: wrap with % items.length so the index loops back to 0 after the last item.setIndex(index + 1) captures the index from the render that built the function; two calls in one batch both see the old value and advance once. Fix: use the functional form setIndex((i) => (i + 1) % items.length), which always gets the freshest pending index.cycle every render. Without useCallback, cycle is a new function each render, so === checks fail and memoized children re-render needlessly. Fix: wrap it in useCallback with an empty dependency array, which the functional updater makes safe.index (or items) in the useCallback deps. Adding [index] rebuilds cycle on every change and destroys the stable identity. Because the updater form reads the latest index itself, the dependency array can stay empty.useCycle(items, { initial }) shape could seed useState with a chosen starting position instead of always 0 — useful when the current selection is restored from storage.cycle(-1)-style "previous" can reuse the same wrap math: (i - 1 + items.length) % items.length loops correctly off the front of the list.current changes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a custom React hook that steps through a fixed list of values, one at a time. useCycle('red', 'green', 'blue') hands back the current value (starting with the first) and a cycle function. Each call to cycle() moves to the next value, looping back to the start after the last — like clicking a toggle that rotates through a set of options. You can also pass an index to jump straight to a specific value. It's modeled on framer-motion's hook of the same name.
function useCycle<T>(...items: T[]): [current: T, cycle: (index?: number) => void];
current starts as items[0]. cycle() advances to the next item; cycle(index) jumps directly to items[index]. The cycle function keeps the same identity across every render.
function Theme() {
const [color, cycle] = useCycle('red', 'green', 'blue');
return <button onClick={() => cycle()} style={{ color }}>{color}</button>;
}
// renders 'red'; click → 'green'; click → 'blue'; click → wraps to 'red'
// Advancing wraps around; passing an index jumps directly.
const [value, cycle] = useCycle('a', 'b', 'c'); // value === 'a'
cycle(); // value === 'b'
cycle(); // value === 'c'
cycle(); // value === 'a' (wrapped past the end)
cycle(2); // value === 'c' (jumped to items[2])
cycle() call, current is items[0].cycle() on the last item returns to the first; the list is a loop, not a dead end.cycle accepts an optional index. cycle() steps forward by one; cycle(2) jumps straight to items[2].cycle must have a stable identity. Returning a brand-new function each render is not acceptable here — the same reference must come back across renders, so it's safe to pass to memoized children or effect dependency arrays.You'll hold a position in a fixed list with useState, then hand back the item at that position plus one stable function that moves the position forward or jumps it directly.
Lots of controls rotate through a small set of options: a button that flips between light, dark, and system themes; a status pill that steps "todo → doing → done"; an image carousel that loops. Each is the same shape — a list of values and a "next, please" action that wraps back to the start when it runs off the end. useCycle packages that: give it the values, and it returns the one you're on plus a cycle function that advances to the next, looping around, or jumps straight to an index you name.
Think of the list as a loop and keep a single number — the index — pointing at the current spot. The value you return is just items[index]. Advancing means adding one to the index; wrapping means taking that sum modulo the list length, so after the last index you land back on 0. Jumping is even simpler: set the index to the number you were handed. The list itself never changes — only the pointer moves.
The obvious version reads the current index and sets it to one more:
const { useState } = require('react');
function useCycle(...items) {
const [index, setIndex] = useState(0);
const cycle = (next) => {
if (typeof next === 'number') {
setIndex(next);
} else {
setIndex(index + 1); // advance by one
}
};
return [items[index], cycle];
}
Two things break. First, setIndex(index + 1) never wraps — once index reaches the last slot, the next click sets it past the end and items[index] is undefined. Second, and more subtly, index is a snapshot frozen when the function rendered. If a handler calls cycle() twice in the same event, both closures read the same stale index of 0 and both call setIndex(1); React batches them, the last write wins, and you advance once instead of twice. On top of that, a fresh cycle function is created every render, so its identity changes — passing it to a memoized child or an effect dependency array would misbehave.
const { useState, useCallback } = require('react');
function useCycle(...items) {
const [index, setIndex] = useState(0);
// useCallback with an empty dependency array gives `cycle` ONE identity that
// never changes across renders — safe to pass to memoized children or effect
// deps. The empty deps are sound because every read below uses the functional
// updater form, so the callback never closes over a stale `index`.
const cycle = useCallback(
(next) => {
if (typeof next === 'number') {
// Jump straight to an index. The modulo keeps it inside the list even
// if a caller passes a number past the end.
setIndex(((next % items.length) + items.length) % items.length);
} else {
// Advance by one. The functional updater reads the index React is about
// to apply, so two calls in one batch stack (0 → 1 → 2) instead of both
// reading a stale 0. Modulo wraps the last item back to the first.
setIndex((i) => (i + 1) % items.length);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return [items[index], cycle];
}
module.exports = { useCycle };
Three shifts from the naive version. The advance now uses the functional updater (i) => (i + 1) % items.length, which both wraps with modulo and reads the freshest pending index instead of a frozen snapshot. The jump wraps too, with an extra + items.length so even a negative index lands in range. And cycle is wrapped in useCallback([]), pinning its identity across renders — which is only safe because the updater form means the callback no longer depends on index.
Start with useCycle('red', 'green', 'blue'). The first render calls useState(0), so index is 0 and current is items[0] — 'red'. Now a handler fires cycle() twice in one event:
setIndex((i) => (i + 1) % 3) is queued. React will call it with the latest pending index. Pending starts at 0, so it produces (0 + 1) % 3 = 1.cycle() queues another updater behind it. React calls it with 1 (the pending index after step 1), producing (1 + 1) % 3 = 2.React applies the batch and re-renders once. On that render index is 2, so current is items[2] — 'blue'. Now suppose a later click calls cycle() from index 2: the updater produces (2 + 1) % 3 = 0, wrapping back to 'red'. Had the advance been setIndex(index + 1), both calls in the first batch would have used the render-time index of 0 and the result would have been 'green', not 'blue'.
setIndex(index + 1) runs past the last slot, and items[index] becomes undefined. Fix: wrap with % items.length so the index loops back to 0 after the last item.setIndex(index + 1) captures the index from the render that built the function; two calls in one batch both see the old value and advance once. Fix: use the functional form setIndex((i) => (i + 1) % items.length), which always gets the freshest pending index.cycle every render. Without useCallback, cycle is a new function each render, so === checks fail and memoized children re-render needlessly. Fix: wrap it in useCallback with an empty dependency array, which the functional updater makes safe.index (or items) in the useCallback deps. Adding [index] rebuilds cycle on every change and destroys the stable identity. Because the updater form reads the latest index itself, the dependency array can stay empty.useCycle(items, { initial }) shape could seed useState with a chosen starting position instead of always 0 — useful when the current selection is restored from storage.cycle(-1)-style "previous" can reuse the same wrap math: (i - 1 + items.length) % items.length loops correctly off the front of the list.current changes.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.