Skip to content

React Tutorial Practical Projects

React Counter Project: State, Props and Limits

Lesson 138 of 156 2 min read By Deepak Paswan Updated Oct 10, 2026

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.

App.jsx
JSX
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

  1. State. Two pieces: count and step. Nothing else is stored.
  2. Derived values. atMin and atMax are calculated from count on every render (Derived State). Storing them would risk them disagreeing with count.
  3. One function for all changes. change(amount) adds a positive or negative amount and clamps the result with Math.min and Math.max, so the limits cannot be broken, even by a fast double click.
  4. Updater function. setCount((c) => ...) always starts from the latest value (Updating State Correctly).
  5. Select returns a string. e.target.value is text, so we convert with Number(...), otherwise count + "5" would join strings (Controlled Inputs).
  6. Disabled buttons show what is possible, and prevent useless clicks.
  7. Accessibility. The “+” and “-” buttons have aria-labels that say what they do (“Increase by 5”); the number is in a role="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 MIN and MAX constants? 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

  1. Add a custom step field (a number input) with validation.
  2. Save the count in localStorage with a useLocalStorage hook (Custom Hooks).
  3. Add keyboard shortcuts (ArrowUp, ArrowDown, 0) with an effect that adds and removes a keydown listener.
  4. Add a history list of the last five values.