Counter & Timer App

Build a React counter and timer suite to master state updates and UI conditions

Time to implement the project: ~ 6-10 hours

  • React
  • useState
  • useEffect
  • Event Handling
  • Conditional Rendering
  • Component Composition
  • Chakra UI

In this beginner React project, you will build a compact app that contains two small tools: counters and timers. The counter section must support increment, decrement, and reset actions, plus at least one configurable step value so users can change how much the number increases. The timer section must include a countdown timer and a stopwatch mode. Each timer must support start, pause, resume, and reset behavior with clear status feedback.

You will implement reliable state updates, user-driven events, and conditional UI that changes based on running/paused/completed states. Use Chakra UI to build the interface quickly with consistent buttons, spacing, and accessible form controls. The goal is a clean, predictable experience that makes state transitions obvious and keeps the UI synchronized with the underlying React state at all times.

What This Project Trains in React

This project trains the most important React habit: treating UI as a direct reflection of state. Counters and timers look simple, but they expose common mistakes in state updates, effect cleanup, and event-driven logic. You will learn to design components that update immediately on user input and remain correct over time without drifting out of sync.

By finishing this app, you build confidence with controlled UI modes - running versus paused, enabled versus disabled - and you practice writing React code that stays readable as features expand.

Prerequisites and Setup Knowledge

You should already know basic React and be comfortable running a React project locally. This task expects you to write small components, handle events, and style UI using a component library.

  • React fundamentals: JSX, props, and component structure
  • Using useState for UI state changes
  • Using useEffect for time-based behavior and cleanup
  • Basic JavaScript timing functions (setInterval, clearInterval)
  • Chakra UI basics: Button, Stack, Input, and layout components

Core Requirements for Counters and Timers

A strong submission is judged by correctness, clarity, and stability. Counters must update instantly and never desync. Timers must handle start/pause/reset accurately and avoid multiple intervals running at the same time. These requirements focus on React behavior rather than visual complexity.

Requirement Explanation
Counter with increment, decrement, reset These actions practice basic event handling and state updates in a controlled component.
Configurable step value Step control trains input handling and shows how UI state influences interactions.
Stopwatch mode A stopwatch tests time-based updates and ensures your UI stays consistent during frequent re-renders.
Countdown timer mode Countdown logic trains conditional rendering for “completed” states and prevents negative time bugs.
Start, pause, resume, reset controls Multiple states reinforce predictable transitions and force clean interval management.
Disabled states and status messaging Clear UI rules reduce user errors and make component behavior obvious.
Proper interval cleanup Cleanup prevents duplicated intervals and is considered essential for correct React timer behavior.
Accessible UI via Chakra UI Chakra provides consistent components and keyboard-friendly controls without custom styling overhead.

Tips for Reliable Timer Logic

Treat timer behavior as a state machine: stopped, running, paused, completed. Drive your UI from that state so buttons enable and disable automatically. Use useEffect to start and stop intervals, and keep the interval ID in a ref so it persists without triggering re-renders. For countdown, clamp values at zero and stop the interval the moment the timer completes. For counters, prefer functional state updates to avoid stale values during rapid clicks. When you control interval lifecycle precisely, timer bugs disappear.

  • Use functional updates like setCount(c => c + step) to keep rapid clicks correct
  • Store interval IDs in useRef so you can clear timers reliably across renders
  • Disable “Start” when running and disable “Resume” unless paused to avoid conflicting states
  • Format time consistently (mm:ss or hh:mm:ss) so users read the UI instantly
  • Keep counter and timer logic in separate components to avoid tangled state

Common Mistakes When Building a Counter & Timer App

1. Updating counter state from a stale value

Counter logic looks simple, but it can still introduce state bugs. A common mistake is updating the counter using the current value from the render instead of the latest state. This becomes visible when users click quickly, when the step value changes, or when several actions happen close together. React state updates may be batched, so relying on an old count value can produce incorrect results.

Problematic approach:


          function Counter() {
            const [count, setCount] = useState(0);
            const [step, setStep] = useState(1);

            function increment() {
              setCount(count + step);
            }

            function decrement() {
              setCount(count - step);
            }

            return (
              <>
                <button onClick={decrement}>-</button>
                <span>{count}</span>
                <button onClick={increment}>+</button>
              </>
            );
          }

This version often works during slow manual testing, but it is less reliable during rapid clicks because count may not represent the newest state at the moment the update is applied.

Better approach:


          function Counter() {
            const [count, setCount] = useState(0);
            const [step, setStep] = useState(1);

            function increment() {
              setCount((currentCount) => currentCount + step);
            }

            function decrement() {
              setCount((currentCount) => currentCount - step);
            }

            function reset() {
              setCount(0);
            }

            return (
              <>
                <button onClick={decrement}>-</button>
                <span>{count}</span>
                <button onClick={increment}>+</button>
                <button onClick={reset}>Reset</button>
              </>
            );
          }

Step input validation:


          function updateStep(event) {
            const nextStep = Number(event.target.value);

            if (Number.isNaN(nextStep) || nextStep < 1) {
              setStep(1);
              return;
            }

            setStep(nextStep);
          }

Pay attention to: Use functional state updates for counter actions. This keeps the counter correct during rapid clicks and makes the logic safer when the app grows.

2. Starting multiple intervals at the same time

Timer bugs often happen because the app creates a new interval every time the user clicks Start. If the Start button remains clickable while the timer is already running, the app may create two, three, or more intervals. The timer then appears to speed up, pause incorrectly, or continue running after reset.

Problematic code:


          function Stopwatch() {
            const [seconds, setSeconds] = useState(0);

            function start() {
              setInterval(() => {
                setSeconds((value) => value + 1);
              }, 1000);
            }

            return (
              <button onClick={start}>
                Start
              </button>
            );
          }

This code never stores the interval ID and never clears the interval. Every Start click creates another running timer.

Better approach:


          function Stopwatch() {
            const [seconds, setSeconds] = useState(0);
            const [status, setStatus] = useState("stopped");
            const intervalRef = useRef(null);

            useEffect(() => {
              if (status !== "running") {
                return;
              }

              intervalRef.current = setInterval(() => {
                setSeconds((value) => value + 1);
              }, 1000);

              return () => {
                clearInterval(intervalRef.current);
              };
            }, [status]);

            function start() {
              setStatus("running");
            }

            function pause() {
              setStatus("paused");
            }

            function reset() {
              clearInterval(intervalRef.current);
              setSeconds(0);
              setStatus("stopped");
            }

            return (
              <>
                <button disabled={status === "running"} onClick={start}>
                  Start
                </button>

                <button disabled={status !== "running"} onClick={pause}>
                  Pause
                </button>

                <button onClick={reset}>
                  Reset
                </button>
              </>
            );
          }

Pay attention to: Timer intervals need lifecycle control. Start the interval only when the timer is running, clean it up when status changes, and disable buttons that would create conflicting states.

3. Letting countdown values go below zero

Countdown timers need a clear completion rule. Beginners often subtract one second forever and only change the UI text when the value becomes negative. This creates awkward states like -1, -2, or a timer that visually says completed but still runs in the background.

Problematic approach:


          useEffect(() => {
            if (!isRunning) return;

            const intervalId = setInterval(() => {
              setRemainingSeconds((seconds) => seconds - 1);
            }, 1000);

            return () => clearInterval(intervalId);
          }, [isRunning]);

This interval never checks whether the countdown has reached zero. The state keeps decreasing unless another part of the app stops it.

Better approach:


          useEffect(() => {
            if (status !== "running") {
              return;
            }

            const intervalId = setInterval(() => {
              setRemainingSeconds((seconds) => {
                if (seconds <= 1) {
                  clearInterval(intervalId);
                  setStatus("completed");
                  return 0;
                }

                return seconds - 1;
              });
            }, 1000);

            return () => clearInterval(intervalId);
          }, [status]);

Safer completion UI:


          const timerMessage = useMemo(() => {
            if (status === "completed") {
              return "Time is up!";
            }

            if (status === "paused") {
              return "Timer paused";
            }

            if (status === "running") {
              return "Timer running";
            }

            return "Timer ready";
          }, [status]);

Pay attention to: Clamp countdown values at zero and stop the timer immediately when it completes. A countdown should have explicit states such as ready, running, paused, and completed.

4. Mixing stopwatch and countdown logic in one tangled state

Since stopwatch and countdown both use seconds, it is tempting to control both modes with the same variables. This can quickly create bugs: the stopwatch starts from the countdown duration, the countdown resumes from stopwatch time, or reset clears one mode but not the other. Even in a beginner app, separate responsibilities make the logic easier to understand.

Problematic state:


          const [seconds, setSeconds] = useState(0);
          const [isRunning, setIsRunning] = useState(false);
          const [mode, setMode] = useState("stopwatch");

          function reset() {
            setSeconds(0);
            setIsRunning(false);
          }

This may work at first, but seconds means different things depending on the mode. In stopwatch mode it means elapsed time. In countdown mode it means remaining time.

Better structure:


          const [activeTool, setActiveTool] = useState("stopwatch");

          const [stopwatch, setStopwatch] = useState({
            elapsedSeconds: 0,
            status: "stopped"
          });

          const [countdown, setCountdown] = useState({
            initialSeconds: 300,
            remainingSeconds: 300,
            status: "ready"
          });

Separate reset actions:


          function resetStopwatch() {
            setStopwatch({
              elapsedSeconds: 0,
              status: "stopped"
            });
          }

          function resetCountdown() {
            setCountdown((current) => ({
              ...current,
              remainingSeconds: current.initialSeconds,
              status: "ready"
            }));
          }

Pay attention to: Stopwatch and countdown are related UI tools, but they should not share every piece of state. Keep each mode independent and use a parent component only to switch which tool is visible.

5. Formatting time only as raw seconds

A timer that displays 3671 technically works, but it is not user-friendly. Timers should be easy to scan. A common mistake is rendering raw seconds everywhere and postponing formatting until the end. It is better to create one formatting helper early and use it consistently across stopwatch and countdown modes.

Problematic rendering:


          <Text fontSize="4xl">
            {seconds}
          </Text>

Raw seconds are hard to read after the timer passes one minute. Users expect timer formats such as 05:00 or 01:01:11.

Better approach:


          function formatTime(totalSeconds) {
            const hours = Math.floor(totalSeconds / 3600);
            const minutes = Math.floor((totalSeconds % 3600) / 60);
            const seconds = totalSeconds % 60;

            const paddedMinutes = String(minutes).padStart(2, "0");
            const paddedSeconds = String(seconds).padStart(2, "0");

            if (hours > 0) {
              return `${hours}:${paddedMinutes}:${paddedSeconds}`;
            }

            return `${paddedMinutes}:${paddedSeconds}`;
          }

Chakra UI display example:


          <Text
            as="output"
            fontSize="5xl"
            fontWeight="bold"
            aria-live="polite"
          >
            {formatTime(remainingSeconds)}
          </Text>

Duration input guard:


          function updateCountdownMinutes(value) {
            const minutes = Number(value);

            if (Number.isNaN(minutes) || minutes < 1) {
              setCountdown((current) => ({
                ...current,
                initialSeconds: 60,
                remainingSeconds: 60
              }));
              return;
            }

            setCountdown((current) => ({
              ...current,
              initialSeconds: minutes * 60,
              remainingSeconds: minutes * 60
            }));
          }

Pay attention to: Format time consistently and validate duration inputs. A small app becomes much more polished when users can immediately understand what they are seeing.

By completing this project, you'll gain a solid understanding of building React components that update state predictably, respond to user events, and render different UI states based on conditions. You will practice interval-based logic with correct cleanup, build clear controls for running and paused modes, and deliver a polished layout using Chakra UI. This foundation prepares you for more complex React apps that rely on timers, user input, and multi-step UI flows.

Reference Implementations Worth Studying

Direct timer and stopwatch reference:
Shreeprada - Google Timer X Stopwatch

This is the closest reference for the timer side of the Counter & Timer App. It is a React project that recreates a Google-style timer and stopwatch interface using React Hooks and Chakra UI. Since your project also uses Chakra UI and includes both stopwatch and countdown behavior, this repository is a strong visual and interaction reference.

Pay particular attention to:

  • How the timer and stopwatch are presented as two related but separate tools.
  • How Chakra UI components help create a clean interface without heavy custom CSS.
  • How start, stop, and reset controls are arranged so users understand the current mode.
  • How a familiar Google-style layout can make a simple timer project feel more polished.
  • What extra validation and interval cleanup you should add to make your own version more reliable.

Use this implementation as the closest practical reference. It is especially helpful for UI structure, Chakra spacing, and the user experience of switching between timer and stopwatch behavior.

Stronger useEffect cleanup reference:
Blevs - React Timer

This repository is useful because it focuses on the exact React lessons that timer projects usually expose: how useState updates values, how useEffect runs side effects, and why cleanup functions matter. It is a good reference for understanding the logic behind timers instead of only copying the UI.

When studying the code, focus on:

  • How timer behavior is connected to React state instead of direct DOM manipulation.
  • How useEffect controls when interval logic should run.
  • Why cleanup functions are necessary to prevent duplicated intervals.
  • How small timer examples can teach important React lifecycle habits.
  • How you could extend the same logic into pause, resume, reset, and countdown completion states.

What makes this reference valuable is its teaching focus. Use it to strengthen the underlying React reasoning, then apply those lessons to a more complete Counter & Timer App with separate stopwatch, countdown, and counter sections.

Alternative state-management reference:
Redux Example - Counter

This example is useful as an alternative direction for the counter part of the project. Instead of keeping all counter logic inside a component with local state, the Redux counter example shows the idea of predictable state updates through actions and reducer logic. For a beginner project, Redux is not required, but the pattern is still worth studying.

While reviewing this example, examine:

  • How increment and decrement actions describe what happened instead of directly changing UI values.
  • How reducer logic keeps state transitions predictable and easy to test.
  • How a simple counter can be separated into state, actions, and presentation.
  • How the same pattern could support step values, reset actions, or multiple counters.
  • Why local useState is enough for your app now, but external state patterns become useful as apps grow.

Use this as a comparison point, not as a requirement. Your main Counter & Timer App can stay local-state based, but studying this example helps you understand how simple state transitions can become more formal and testable.

© 2026 ReadyToDev.Pro. All rights reserved.

Methodology

Privacy Policy

Terms & Conditions