Build a custom React hook that manages a single number. useCounter is the "hello world" of custom hooks: it wraps useState and hands back the current count plus three helpers — increment, decrement, and reset. The point isn't the arithmetic; it's learning how a hook bundles state together with the functions that change it into one tidy, reusable package a component can drop in with a single line.
function useCounter(initialValue?: number): {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
initialValue defaults to 0 when omitted. reset always returns the count to that original initialValue, not to 0.
function Stepper() {
const { count, increment, decrement, reset } = useCounter(10);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>−</button>
<button onClick={reset}>reset</button>
</div>
);
}
// renders 10; + → 11; − → 9; reset → back to 10
// Calling a setter several times in one event should accumulate.
// Three increments from 0 must land on 3, not on 1.
increment();
increment();
increment();
// count === 3
decrement has no lower bound — from 0 it yields -1. Clamping is out of scope here.reset targets the initial value. If the hook was created with useCounter(7), reset restores 7.You'll wrap a single piece of useState in a small bundle of named actions, so any component can manage a number without re-writing the same three handlers.
Almost every interface has a number that goes up and down on command: a quantity stepper in a cart, a pagination control, a "load 10 more" button, a zoom level. Each one needs the same machinery — hold the value, nudge it up, nudge it down, snap it back to the start. A custom hook lets you write that machinery once and reuse it. useCounter owns the number with useState and returns the value alongside three verbs — increment, decrement, reset — that are the only ways to change it.
A custom hook is just a function that calls other hooks. useCounter calls useState to get a count and a setter, then closes over that setter inside three small functions and returns them. To a component, the whole thing looks like one self-contained widget: read count to render, call a verb to change it. Each verb asks React to re-render with a new value, and on that next render useCounter runs again and hands back the updated count.
The obvious move is to read count and set it to one more or one less:
const { useState } = require('react');
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
module.exports = { useCounter };
For a single click this looks right, and it usually seems to work. The trap shows up the moment two updates happen in one go. count is a value captured when the function rendered — a snapshot, frozen at, say, 0. If a handler calls increment() three times in the same event, all three closures read that same frozen count of 0 and all three call setCount(1). React batches them, the last one wins, and you land on 1 instead of 3. The bug is invisible until something increments more than once per render.
const { useState } = require('react');
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
// Functional updates: instead of "set it to count + 1" (where count is a
// frozen snapshot from render time), say "set it to whatever-it-is + 1".
// React feeds each updater the latest pending value, so calls stack
// correctly even when several land in the same batched re-render.
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
// reset goes back to the value the hook was created with, not to 0.
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
module.exports = { useCounter };
The only change is the shape of the update. Passing a value (count + 1) ties the result to a stale snapshot; passing a function ((c) => c + 1) tells React to compute the next state from the previous state it's about to apply. reset can stay a plain value because it doesn't depend on the current count — it always targets initialValue.
Start with useCounter(0). The first render calls useState(0), so count is 0, and the hook returns the three verbs. Now a click handler fires increment() three times in a row:
setCount((c) => c + 1) is queued. React will call this updater with the latest pending value. Pending starts at 0, so this updater produces 1.setCount((c) => c + 1) is queued behind it. React calls it with 1 (the pending value after step 1), producing 2.2, producing 3.React applies the batch and re-renders once. On that render, useState returns 3, so count is 3 and the screen updates. Had these been setCount(count + 1), every updater would have used the render-time count of 0, and the answer would have been 1.
setCount(count + 1) captures the count from the render that created the function. Inside an event that fires multiple updates, that snapshot is out of date. Fix: use the functional form setCount((c) => c + 1), which always receives the freshest pending value.0 instead of the initial value. Hardcoding setCount(0) breaks useCounter(7) — reset should restore 7. Fix: close over initialValue and call setCount(initialValue).useState. Writing let count = initialValue makes a plain variable that React doesn't track; it resets to the initial value on every render and never reflects clicks. State must come from useState so it survives across renders.if (open) { useCounter() } violates the rules of hooks — hooks must run in the same order every render. Always call useCounter at the top level of the component.increment/decrement/reset functions. That's fine here, but if you pass them to a memoized child they'll defeat the memoization. Wrapping each in useCallback (with an empty dependency array, since the functional updater needs no dependencies) keeps the same reference across renders.useCounter(initial, { min, max }) variant could clamp the value so it can't run past a floor or ceiling — handy for quantity pickers.step option lets increment/decrement move by 5 or 10 instead of 1, while the functional-update pattern stays exactly the same.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 manages a single number. useCounter is the "hello world" of custom hooks: it wraps useState and hands back the current count plus three helpers — increment, decrement, and reset. The point isn't the arithmetic; it's learning how a hook bundles state together with the functions that change it into one tidy, reusable package a component can drop in with a single line.
function useCounter(initialValue?: number): {
count: number;
increment: () => void;
decrement: () => void;
reset: () => void;
};
initialValue defaults to 0 when omitted. reset always returns the count to that original initialValue, not to 0.
function Stepper() {
const { count, increment, decrement, reset } = useCounter(10);
return (
<div>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={decrement}>−</button>
<button onClick={reset}>reset</button>
</div>
);
}
// renders 10; + → 11; − → 9; reset → back to 10
// Calling a setter several times in one event should accumulate.
// Three increments from 0 must land on 3, not on 1.
increment();
increment();
increment();
// count === 3
decrement has no lower bound — from 0 it yields -1. Clamping is out of scope here.reset targets the initial value. If the hook was created with useCounter(7), reset restores 7.You'll wrap a single piece of useState in a small bundle of named actions, so any component can manage a number without re-writing the same three handlers.
Almost every interface has a number that goes up and down on command: a quantity stepper in a cart, a pagination control, a "load 10 more" button, a zoom level. Each one needs the same machinery — hold the value, nudge it up, nudge it down, snap it back to the start. A custom hook lets you write that machinery once and reuse it. useCounter owns the number with useState and returns the value alongside three verbs — increment, decrement, reset — that are the only ways to change it.
A custom hook is just a function that calls other hooks. useCounter calls useState to get a count and a setter, then closes over that setter inside three small functions and returns them. To a component, the whole thing looks like one self-contained widget: read count to render, call a verb to change it. Each verb asks React to re-render with a new value, and on that next render useCounter runs again and hands back the updated count.
The obvious move is to read count and set it to one more or one less:
const { useState } = require('react');
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
const increment = () => setCount(count + 1);
const decrement = () => setCount(count - 1);
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
module.exports = { useCounter };
For a single click this looks right, and it usually seems to work. The trap shows up the moment two updates happen in one go. count is a value captured when the function rendered — a snapshot, frozen at, say, 0. If a handler calls increment() three times in the same event, all three closures read that same frozen count of 0 and all three call setCount(1). React batches them, the last one wins, and you land on 1 instead of 3. The bug is invisible until something increments more than once per render.
const { useState } = require('react');
function useCounter(initialValue = 0) {
const [count, setCount] = useState(initialValue);
// Functional updates: instead of "set it to count + 1" (where count is a
// frozen snapshot from render time), say "set it to whatever-it-is + 1".
// React feeds each updater the latest pending value, so calls stack
// correctly even when several land in the same batched re-render.
const increment = () => setCount((c) => c + 1);
const decrement = () => setCount((c) => c - 1);
// reset goes back to the value the hook was created with, not to 0.
const reset = () => setCount(initialValue);
return { count, increment, decrement, reset };
}
module.exports = { useCounter };
The only change is the shape of the update. Passing a value (count + 1) ties the result to a stale snapshot; passing a function ((c) => c + 1) tells React to compute the next state from the previous state it's about to apply. reset can stay a plain value because it doesn't depend on the current count — it always targets initialValue.
Start with useCounter(0). The first render calls useState(0), so count is 0, and the hook returns the three verbs. Now a click handler fires increment() three times in a row:
setCount((c) => c + 1) is queued. React will call this updater with the latest pending value. Pending starts at 0, so this updater produces 1.setCount((c) => c + 1) is queued behind it. React calls it with 1 (the pending value after step 1), producing 2.2, producing 3.React applies the batch and re-renders once. On that render, useState returns 3, so count is 3 and the screen updates. Had these been setCount(count + 1), every updater would have used the render-time count of 0, and the answer would have been 1.
setCount(count + 1) captures the count from the render that created the function. Inside an event that fires multiple updates, that snapshot is out of date. Fix: use the functional form setCount((c) => c + 1), which always receives the freshest pending value.0 instead of the initial value. Hardcoding setCount(0) breaks useCounter(7) — reset should restore 7. Fix: close over initialValue and call setCount(initialValue).useState. Writing let count = initialValue makes a plain variable that React doesn't track; it resets to the initial value on every render and never reflects clicks. State must come from useState so it survives across renders.if (open) { useCounter() } violates the rules of hooks — hooks must run in the same order every render. Always call useCounter at the top level of the component.increment/decrement/reset functions. That's fine here, but if you pass them to a memoized child they'll defeat the memoization. Wrapping each in useCallback (with an empty dependency array, since the functional updater needs no dependencies) keeps the same reference across renders.useCounter(initial, { min, max }) variant could clamp the value so it can't run past a floor or ceiling — handy for quantity pickers.step option lets increment/decrement move by 5 or 10 instead of 1, while the functional-update pattern stays exactly the same.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.