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!
Member discussion