Runnable lesson · press Try it on the examples to edit and run them here.
The first project is small on purpose: it uses state, events, derived values and conditional rendering, which are the foundation of everything else.
Requirements
- Show a number that starts at 0.
- Buttons to increase, decrease and reset.
- A step size input (1, 5, 10) that changes how much each click adds.
- The number must stay between 0 and 20. Buttons that cannot do anything are disabled.
- A message when the limit is reached.
- Usable with the keyboard and with a screen reader.
The solution
Run it, click the buttons and change the step size. Then read the walkthrough.
const MIN = 0;
const MAX = 20;
function App() {
const [count, setCount] = React.useState(0);
const [step, setStep] = React.useState(1);
// Derived values: no extra state needed
const atMin = count <= MIN;
const atMax = count >= MAX;
function change(amount) {
// Clamp so the value always stays inside the limits
setCount((c) => Math.min(MAX, Math.max(MIN, c + amount)));
}
return (
<main style={{ fontFamily: "system-ui, sans-serif", maxWidth: 320 }}>
<style>{`
.row { display: flex; gap: 8px; align-items: center; margin: 12px 0; }
button { font: inherit; padding: 6px 14px; border: 2px solid #1a5276; border-radius: 6px; background: #fff; cursor: pointer; }
button:hover:not(:disabled) { background: #1a5276; color: #fff; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
button:focus-visible, select:focus-visible { outline: 3px solid #ca4b00; outline-offset: 2px; }
.value { font-size: 56px; font-weight: 700; margin: 0; }
`}</style>
<h1>Counter</h1>
<p className="value" role="status" aria-live="polite">Count: {count}</p>
<div className="row">
<button onClick={() => change(-step)} disabled={atMin} aria-label={"Decrease by " + step}>-</button>
<button onClick={() => change(step)} disabled={atMax} aria-label={"Increase by " + step}>+</button>
<button onClick={() => setCount(0)} disabled={count === 0}>Reset</button>
</div>
<label className="row">
Step
<select aria-label="Step" value={step} onChange={(e) => setStep(Number(e.target.value))}>
<option value={1}>1</option>
<option value={5}>5</option>
<option value={10}>10</option>
</select>
</label>
{atMax && <p>Maximum reached</p>}
{atMin && <p>Minimum reached</p>}
</main>
);
}
Walkthrough
- State. Two pieces:
countandstep. Nothing else is stored. - Derived values.
atMinandatMaxare calculated fromcounton every render (Derived State). Storing them would risk them disagreeing withcount. - One function for all changes.
change(amount)adds a positive or negative amount and clamps the result withMath.minandMath.max, so the limits cannot be broken, even by a fast double click. - Updater function.
setCount((c) => ...)always starts from the latest value (Updating State Correctly). - Select returns a string.
e.target.valueis text, so we convert withNumber(...), otherwisecount + "5"would join strings (Controlled Inputs). - Disabled buttons show what is possible, and prevent useless clicks.
- Accessibility. The “+” and “-” buttons have
aria-labels that say what they do (“Increase by 5”); the number is in arole="status"live region so screen readers hear changes; focus styles are visible.
Decisions worth noticing
- Why clamp inside the setter instead of only disabling buttons? Disabled buttons are a hint for users; the clamp is the rule. Keep rules in logic, not only in the UI.
- Why
MINandMAXconstants? They have one definition, and the rest of the code reads clearly. Changing the limit is a one-line edit. - Why not
useReducer? Two simple values and one rule do not need it. For more actions, see Reducers and useReducer.
Make it yours
- Add a custom step field (a number input) with validation.
- Save the count in
localStoragewith auseLocalStoragehook (Custom Hooks). - Add keyboard shortcuts (ArrowUp, ArrowDown, 0) with an effect that adds and removes a
keydownlistener. - Add a history list of the last five values.