7 min read

Tutorial: How to build a Pomodoro app

The first step in building the Pomodoro app is breaking down the UI into a component hierarchy. This is fundamental to "thinking in React" and this process is nicely described in the React docs.

Step 1: Break the UI into a component hierarchy

In our case, we could split the app in two components:

  • the PomodoroApp parent component, that can keep track of how many sessions the user had and when is time to take a break
  • the TimedSession app, that tracks the actual countdown and allows the user to start, pause, restart or skip the timer

Step 2: Build a static version in React

Let's now build a static version of this in React. We can use the Vite React starter to get a project up and running.

We can update the App.jsx file that comes with the Vite boilerplate to include our PomodoroApp component:

import PomodoroApp from "./components/PomodoroApp";

function App() {
  return <PomodoroApp />;
}

export default App;

The Pomodoro app will then show the current session and include the TimedSession:

import TimedSession from "./TimedSession";

const PomodoroApp = () => {
  return (
    <div
      style={{
        margin: "auto",
        maxWidth: "500px",
        textAlign: "center",
        fontSize: "1.5em",
      }}
    >
      <p>Session 1 of 4</p>
      <TimedSession />
    </div>
  );
};

export default PomodoroApp;

And last, the timed session:

const TimedSession = () => {
  return (
    <div style={{ border: "1px solid black" }}>
      <p style={{ fontSize: "2.5em" }}>25:00</p>
      <div
        style={{
          marginBottom: "2em",
          display: "flex",
          gap: "20px",
          justifyContent: "center",
        }}
      >
        <button>Restart</button>
        <button>Start / Pause</button>
        <button>Skip</button>
      </div>
    </div>
  );
};

export default TimedSession;

Step 3: Find the minimal but complete representation of UI state

Our app looks good, but everything is hardcoded. What does our app need to "remember" as the user interacts with it?

"Think of state as the minimal set of changing data that your app needs to remember."

Here is my take - the app needs to remember:

  • the session count - how many sessions the user already did
  • the type of session - is the user taking a short or long break? or is he during focused time?
  • whether the timer is running or not
  • how much time is left of the timer

Besides these, you'll notice the app also needs to "know" what the default durations are for breaks and focused sessions, however, as we don't allow the user to configure them, they won't count as state and we can just hardcode them for now.

Step 4: Identify where your state should live

Looking at the TimedSession component, we see it can hold its own state - how much time is left of the timer and whether the timer is running or not - the parent component doesn't need to know these details.
However, the PomodoroApp does need to keep track of what type of session is currently running, to know what duration to pass to the TimedSession. So the PomodoroApp will remember the session count and the type of session.

Here is the updated PomodoroApp component:

import { useState } from "react";
import TimedSession from "./TimedSession";

const TOTAL_SESSIONS_IN_CYCLE = 4;

const SESSION_TYPE_LABEL = {
  focus: "focus time",
  "short-break": "short break",
  "long-break": "long break",
};

const SESSION_DURATION_IN_MINUTES = {
  focus: 25,
  "short-break": 5,
  "long-break": 20,
};

const PomodoroApp = () => {
  const [currentSession, setCurrentSession] = useState(1);
  const [currentSessionType, setCurrentSessionType] = useState("focus");

  return (
    <div
      style={{
        margin: "auto",
        maxWidth: "500px",
        textAlign: "center",
        fontSize: "1.5em",
      }}
    >
      <p>
        Session {currentSession} of {TOTAL_SESSIONS_IN_CYCLE} (
        {SESSION_TYPE_LABEL[currentSessionType]})
      </p>
      <TimedSession
        initialDuration={SESSION_DURATION_IN_MINUTES[currentSessionType]}
      />
    </div>
  );
};

export default PomodoroApp;

The component only remembers the session count and type - the details of the timer can be handled by the TimedSession component itself:

import { useState } from "react";

const formatTime = (date) => {
  return new Intl.DateTimeFormat("en-US", {
    minute: "numeric",
    second: "numeric",
  }).format(date);
};

const TimedSession = ({ initialDuration }) => {
  const [timeRemaining, setTimeRemaining] = useState(
    new Date(initialDuration * 60 * 1000)
  );
  const [isRunning, setIsRunning] = useState(false);

  return (
    <div style={{ border: "1px solid black" }}>
      <p style={{ fontSize: "2.5em" }}>{formatTime(timeRemaining)}</p>
      <div
        style={{
          marginBottom: "2em",
          display: "flex",
          gap: "20px",
          justifyContent: "center",
        }}
      >
        {isRunning ? (
          <>
            <button>Restart</button>
            <button>Pause</button>
            <button>Skip</button>
          </>
        ) : (
          <button>Start</button>
        )}
      </div>
    </div>
  );
};

export default TimedSession;

Notice we're formatting the time using the Intl.DateTimeFormat browser API.

Step 5: Add inverse data flow

Lastly, it's time for our app to react to user input.

Let's start with the TimedSession - what interactions can the user do?

  • he can press "Start" -> and we want to start an interval
  • he can press "Pause" -> and we want to cancel the interval
  • he can press "Restart" -> we will stop current interval and start a new one
  • he can press "Skip" -> we need to notify the parent component that the current session is changing

To start an interval, we can use the setInterval browser API, which returns an interval id. Then, to stop the interval we pass the id to clearInterval: clearInterval(intervalId)

Since in our case, the countdown starts on user interaction, we can start the interval in an event handler:

  const intervalIdRef = useRef(null);
  
  const handleStart = () => {
    intervalIdRef.current = setInterval(() => {
      setTimeRemaining(
        (prevTimeRemaining) => new Date(prevTimeRemaining.getTime() - 1000)
      );
    }, 1000);
  };

  const stopInterval = () => {
    clearInterval(intervalIdRef.current);
    intervalIdRef.current = null;
    setIsRunning(false);
  };

  const handlePause = () => {
    stopInterval();
  };

  const handleRestart = () => {
    stopInterval();
    setTimeRemaining(new Date(initialDuration * 60 * 1000));
  };
  
  // Cleanup the timer on unmount
  useEffect(() => {
    return () => {
      stopInterval();
    };
  }, []);

....

 {isRunning ? (
	  <>
		<button onClick={handleRestart}>Restart</button>
		<button onClick={handlePause}>Pause</button>
		<button onClick={onSkipSession}>Skip</button>
	  </>
	) : (
	  <button onClick={handleStart}>Start</button>
	)}

Notice we're saving the interval id inside a ref, as opposed to in a state variable. This guarantees we always have access to the latest value of the interval id and we can safely clean it up on unmount.

Next, for "skipping" a session, we can add a new prop that will notify the parent component when the user clicked "Skip" - onSkipSession, so that it can react to this user interaction.

Now that we're done with all the user interactions, there's one more "event" that we will need to notify the parent component about - when the countdown completes!
This way, the parent component can continue to the next break, as needed.

Let's add an onCompleteSession prop that we can call when the timer reaches zero:

  useEffect(() => {
    if (timeRemaining.getTime() === 0) {
      stopInterval();
      onCompleteSession();
    }
  }, [timeRemaining, onCompleteSession]);

The completed TimedSession will then look as follows:

import { useState, useEffect, useRef } from "react";

const formatTime = (date) => {
  return new Intl.DateTimeFormat("en-US", {
    minute: "numeric",
    second: "numeric",
  }).format(date);
};

const TimedSession = ({
  initialDuration,
  onSkipSession,
  onCompleteSession,
}) => {
  const [timeRemaining, setTimeRemaining] = useState(
    new Date(initialDuration * 60 * 1000)
  );
  const [isRunning, setIsRunning] = useState(false);
  const intervalIdRef = useRef(null);

  const handleStart = () => {
    intervalIdRef.current = setInterval(() => {
      setTimeRemaining(
        (prevTimeRemaining) => new Date(prevTimeRemaining.getTime() - 1000)
      );
    }, 1000);
    setIsRunning(true);
  };

  const stopInterval = () => {
    clearInterval(intervalIdRef.current);
    intervalIdRef.current = null;
    setIsRunning(false);
  };

  const handlePause = () => {
    stopInterval();
  };

  const handleRestart = () => {
    stopInterval();
    setTimeRemaining(new Date(initialDuration * 60 * 1000));
  };

  // Cleanup the timer on unmount
  useEffect(() => {
    return () => {
      stopInterval();
    };
  }, []);

  useEffect(() => {
    if (timeRemaining.getTime() === 0) {
      stopInterval();
      onCompleteSession();
    }
  }, [timeRemaining, onCompleteSession]);

  return (
    <div style={{ border: "1px solid black" }}>
      <p style={{ fontSize: "2.5em" }}>{formatTime(timeRemaining)}</p>
      <div
        style={{
          marginBottom: "2em",
          display: "flex",
          gap: "20px",
          justifyContent: "center",
        }}
      >
        {isRunning ? (
          <>
            <button onClick={handleRestart}>Restart</button>
            <button onClick={handlePause}>Pause</button>
            <button onClick={onSkipSession}>Skip</button>
          </>
        ) : (
          <button onClick={handleStart}>Start</button>
        )}
      </div>
    </div>
  );
};

export default TimedSession;

Next, let's consider the PomodoroApp component - how can the user interact with this screen?
The user can only "jump" to the next session by using the "Skip" button.
Besides this, the component will also need to handle the "session complete" event, when the timer reaches zero.

Both of these are basically ways to "go to next session":

  • if the user finished or is during a focus session, next session will be a break
    • if user completed 4 focused sessions, we go to a long break
    • otherwise, we go to a short break
  • if the user is during a break, next session will be a focused time; we will also increase the session counter

Here is how a goToNextSession method could look like:

const goToNextSession = () => {
	if (currentSessionType === "focus") {
	  if (currentSession < 4) {
		setCurrentSessionType("short-break");
	  } else {
		setCurrentSessionType("long-break");
	  }
	} else {
	  setCurrentSession(currentSession + 1);
	  setCurrentSessionType("focus");
	}
};

Next, we can call this method on the onSkipSession and onCompleteSession props.

<TimedSession
	initialDuration={SESSION_DURATION_IN_MINUTES[currentSessionType]}
	onCompleteSession={() => goToNextSession()}
	onSkipSession={() => goToNextSession()}
  />

After goToNextSession is called, the state updates, which will trigger a rerender to load a new "timed session" screen. However, you'll notice the timer doesn't actually get reset - that's because the component was not unmounted, so the previous configuration is still "alive". Simplest way to avoid this is to pass a key to the TimedSession, so we are sure we always get a new component when switching from one session to the next:

<TimedSession
	key={`${currentSession}-${currentSessionType}`}
	initialDuration={SESSION_DURATION_IN_MINUTES[currentSessionType]}
	onCompleteSession={() => goToNextSession()}
	onSkipSession={() => goToNextSession()}
  />

And that's it! Our pomodoro timer is now complete!

Note: Some of you might notice we don't have a sound when the timer completes 😄 I chose to skip this feature from the tutorial, as it's quite straightforward to add. I recommend using Josh Comeau's use-sound hook to get going!

How did you complete this challenge? Do you still have any questions? Don't hesitate to share them in the comments below!