¡Acompáñame a construir un Pomodoro con React Hooks!
- ¿Qué es Pomodoro?
- Preparando el terreno
- Componentes
- Componentes: Break y Session
- Componentes: Timer
- Componentes: App
Nota: Este tutorial no va a presuponer que tengas ningún conocimiento, así que si ya sabes React es posible que resulte aburrido y quieras ir directamente al código del proyecto terminado.
¿Qué es Pomodoro?
Se trata de una metodología que consiste en intercalar tiempos más o menos prolongados de concentración, con unos minutos de descanso.
Se utiliza mucho para estudiar, pero muchas otras personas lo usan también para programar, ya que ayuda a no olvidarnos de beber, comer, descansar la vista o simplemente estirar las piernas.
Hoy en día disponemos de muchas aplicaciones que nos permiten seguir este método de una forma muy cómoda. Este es el funcionamiento que tienen casi todas en común:
- Marcas el tiempo que quieres que dure la sesión y el de los descansos.
- Pulsas "Start" y empieza la cuenta atrás.
- Cuando el tiempo se agota, recibes una notificación que por lo general suele ser auditiva.
- Aquí hay diferentes opciones. Algunas aplicaciones simplemente cambian el modo (de sesión a descanso y viceversa) y empiezan la cuenta atrás del modo correspondiente. Otras detienen el temporizador pero no siguen corriendo hasta que no es activado manualmente. En este tutorial vamos a hacerlo de la primera manera.
- ¡Y ya está! Puedes añadir más o menos características, pero solo con eso ya tienes una aplicación funcional.
Preparando el terreno
[ Si no puedes o quieres realizar el proyecto en el dispositivo en el que te encuentras, también puedes hacer fork a este proyecto en Codepen. El botón de fork lo encuentras en la parte de abajo de la página, por la zona de la derecha.
Puedes, entonces, pasar a la sección de Componentes. Aquí no necesitas importar nada, y ya he declarado los componentes que necesitas de React Bootstrap, así que solo tienes que usarlos.]
Así que vamos a empezar. Lo primero que necesitamos es crear un proyecto con React en local:
npx create-react-app pomodoro-react
cd pomodoro-react
npm start
! Ten en cuenta que necesitas tener Node instalado para que funcione.
Esto hará que se abra un servidor en http://localhost:3000/ donde podrás ver que React ya está funcionando. Para terminar de comprobar que todo va correctamente, vamos a ir a la carpeta src
y abrir App.js
con nuestro IDE o editor de texto favorito y a cambiar el código que tenemos por este:
import React from 'react';
import logo from './logo.svg';
import './App.css';
function App() {
return (
<div className="App">
<p>Holi, soy React</p>
</div>
);
}
export default App;
Al guardar el archivo, el servidor debería actualizarse y mostrarnos solamente "Holi, soy React". Si es así, podemos pasar al siguiente paso: instalar React Bootstrap.
Para ello vamos a usar el siguiente comando:
npm install react-bootstrap bootstrap
Comprobemos de nuevo de una manera sencilla que Bootstrap funciona cambiando algunas cosas en App.js
:
import React from 'react';
import logo from './logo.svg';
import './App.css';
import {Button} from 'react-bootstrap';
function App() {
return (
<div className="App">
<Button>Holi, soy React</Button>
</div>
);
}
export default App;
Para que funcione, necesitas importar los estilos de Bootstrap en el archivo index.js
(también en la carpeta dist
) incluyendo la siguiente línea justo antes del index.css
:
import 'bootstrap/dist/css/bootstrap.css';
Ahora nuestro texto simplón debería haberse convertido en un botón azul. Si es así, ¡enhorabuena! Ya podemos empezar a programar.
Componentes
Vamos a utilizar cuatro componentes: App, Break, Session y Timer. Dado que salvo App van a ser muy pocas líneas de código, vamos a escribir todo en el propio archivo App.js
, pero si crees que para ti es más cómodo o vas a añadir más funcionalidades es posible que quieras tener cada componente en un archivo separado. En este caso lo único que tienes que recordar es importarlos a App.js
.
# Break y Session
Aunque son dos componentes distintos, la lógica de ambos es la misma y con que construyamos uno con solo cambiar un par de nombres tendremos el otro.
Vamos a utilizar Break de ejemplo. Este componente solo va a encargarse de recibir y mostrar la cantidad de minutos que queremos para los descansos.
Para ello, usamos nuestro primer Hook: useState. Para nuestra comodidad, vamos a importarlo directamente junto a React. Entre unas cosas y otras, App.js
se quedaría en algo parecido a esto a estas alturas:
import React, {useState} from 'react';
import logo from './logo.svg';
import './App.css';
import {Button, Col, Container, Row} from 'react-bootstrap';
const Break = () => {
const [breakLength, setBreakLength] = useState(5 * 60);
return (
<Col>
<p>Break</p>
<Button variant="info">-</Button>
<span>{breakLength / 60}</span>
<Button variant="info">+</Button>
</Col>
)
}
const App = () => {
return (
<Container className="App">
<Row>
<Break/>
</Row>
</Container>
);
}
export default App;
Fíjate que hemos importado Col, Container y Row de React Bootstrap también para que todo gire correctamente.
Tenemos dos botones para sumar y restar, pero aún no hacen nada. ¡solucionemos eso creando dos funciones!
const Break = () => {
const [breakLength, setBreakLength] = useState(5 * 60);
function decrementBreakLength() {
const decreasedBreakLength = breakLength - 60 > 60 ? breakLength - 60 : 60;
setBreakLength(decreasedBreakLength);
}
function incrementBreakLength() {
const incrementedBreakLength =
breakLength + 60 <= 60 * 60 ? breakLength + 60 : 60 * 60;
setBreakLength(incrementedBreakLength);
}
return (
<Col>
<p>Break</p>
<Button variant="info" onClick={decrementBreakLength}>-</Button>
<span>{breakLength / 60}</span>
<Button variant="info" onClick={incrementBreakLength}>+</Button>
</Col>
)
}
Cuando los botones de restar o sumar son pulsados, llamamos a decrementBreakLength
o incrementBreakLength
respectivamente. Ambas funciones hacen el cálculo correspondiente (restando o sumando 1 minuto) y actualizan el valor de breakLength
usando setBreakLength
. ¡ya tenemos un valor que cambia dinámicamente! Hemos puesto un límite para que no pueda ser menor de un minuto ni mayor a una hora.
Como dije antes, para tener nuestro componente Session solo tenemos que copiar/pegar Break y cambiar los términos correspondientes para que todo coincida:
const Session = () => {
const [sessionLength, setSessionLength] = useState(25 * 60);
function decrementSessionLength() {
const decreasedSessionLength = sessionLength - 60 > 60 ? sessionLength - 60 : 60;
setSessionLength(decreasedSessionLength);
}
function incrementSessionLength() {
const incrementedSessionLength =
sessionLength + 60 <= 60 * 60 ? sessionLength + 60 : 60 * 60;
setSessionLength(incrementedSessionLength);
}
return (
<Col>
<p>Session</p>
<Button variant="info" onClick={decrementSessionLength}>-</Button>
<span>{sessionLength / 60}</span>
<Button variant="info" onClick={incrementSessionLength}>+</Button>
</Col>
)
}
! recuerda añadir Session a tu componente App, justo debajo de Break.
# Timer
Usaremos este componente para mostrar los minutos y segundos que quedan para que termine la sesión o el descanso activados.
Aquí vamos a empezar a ver la necesidad de llevar las funcionalidades de Break y Session al componente padre (App), ya que necesitaremos acceder al valor de breakLength
y sessionLength
para que desde Timer sepamos cuál es el tiempo que tenemos que mostrar.
Así que lo primero que vamos a hacer va a ser llevarnos los hooks y las funciones y a pasar lo que sea necesario como propiedades. El código completo sería el siguiente:
const Break = (props) => {
const { increment, decrement, length } = props;
return (
<Col>
<p>Break</p>
<Button variant="info" onClick={decrement}>-</Button>
<span>{length / 60}</span>
<Button variant="info" onClick={increment}>+</Button>
</Col>
)
}
const Session = (props) => {
const { increment, decrement, length } = props;
return (
<Col>
<p>Session</p>
<Button variant="info" onClick={decrement}>-</Button>
<span>{length / 60}</span>
<Button variant="info" onClick={increment}>+</Button>
</Col>
)
}
const App = () => {
const [breakLength, setBreakLength] = useState(5 * 60);
const [sessionLength, setSessionLength] = useState(25 * 60);
function decrementBreakLength() {
const decreasedBreakLength = breakLength - 60 > 60 ? breakLength - 60 : 60;
setBreakLength(decreasedBreakLength);
}
function incrementBreakLength() {
const incrementedBreakLength =
breakLength + 60 <= 60 * 60 ? breakLength + 60 : 60 * 60;
setBreakLength(incrementedBreakLength);
}
function decrementSessionLength() {
const decreasedSessionLength = sessionLength - 60 > 60 ? sessionLength - 60 : 60;
setSessionLength(decreasedSessionLength);
}
function incrementSessionLength() {
const incrementedSessionLength =
sessionLength + 60 <= 60 * 60 ? sessionLength + 60 : 60 * 60;
setSessionLength(incrementedSessionLength);
}
return (
<Container className="App">
<Row>
<Break
length={breakLength}
decrement={decrementBreakLength}
increment={incrementBreakLength}
/>
<Session
length={sessionLength}
decrement={decrementSessionLength}
increment={incrementSessionLength}
/>
</Row>
</Container>
);
}
Todo debería seguir funcionando igual. No necesitaremos volver a preocuparnos más por los componentes Break y Session. Ahora podemos centrarnos en lo que Timer nos puede aportar.
De momento, vamos a pasarle sessionLength
como un valor estático:
<Container className="App">
<Timer time={sessionLength}/>
<Row>
y a mostrarlo:
const Timer = (props) => {
const {time} = props;
return (
<div>
<p>Session</p>
<p>
{time / 60}:00
</p>
</div>
);
}
Volveremos a él luego para actualizarlo, tras conseguir que App pueda proporcionarle datos dinámicos.
# App
Aquí va todo el meollo de nuestra aplicación. No te preocupes, no es difícil y yo te lo voy a explicar lo mejor que sé. Vamos a dividir mentalmente este Componente en tres partes: useState, useEffect y funciones.
Usando useState vamos a declarar lo siguiente, justo debajo de breakLength
y sessionLength
:
const [mode, setMode] = useState("session");
const [timeLeft, setTimeLeft] = useState();
const [isActive, setIsActive] = useState(false);
const [timeSpent, setTimeSpent] = useState(0);
const [beep] = useState(
new Audio("https://freesound.org/data/previews/523/523960_350703-lq.mp3")
);
const [beepPlaying, setBeepPlaying] = useState(false);
· mode
nos va a indicar en qué modo se encuentra ahora mismo la aplicación (¿modo 'session' o modo 'break'?
· timeLeft
es un parámetro para saber cuánto tiempo queda
· isActive
va a marcar si el temporizador está corriendo o no
· timeSpent
puede ser menos intuitivo fuera de contexto, pero es la variable que nos va a ir guardando el tiempo que ha pasado desde que pulsamos "Start". Es lo que nos sirve para calcular cuánto nos falta.
· beep
es el sonido que usaremos para avisar cuando el temporizador llega a 0
· beepPlaying
nos indica si el sonido está sonando o no.
Ahora vamos a por los useEffect. Recuerda importarlo en la primera línea junto a React y useState de esta forma:
import React, {useState, useEffect} from 'react';
timeLeft
va a ser nuestra referencia para saber cuánto tiempo nos queda y será el parámetro que pasemos al componente Timer, pero necesitamos una forma de que al actualizar breakLength
y sessionLength
timeLeft
cambie su valor acorde a una u otra variable dependiendo del modo en el que estemos:
useEffect(() => {
setTimeLeft(mode == "session" ? sessionLength * 1000 : breakLength * 1000);
}, [sessionLength, breakLength]);
El useEffect que viene a continuación es probablemente el bloque más largo que va a tener nuestra aplicación, así que te lo voy a enseñar e ir explicando por partes:
· Necesitamos "escuchar" los cambios en isActive
y timeSpent
para saber cuándo se activa el temporizador y cómo van pasando los segundos.
useEffect(() => { }, [isActive, timeSpent]);
· Lo primero que vamos a hacer va a ser declarar una variable interval
a null.
let interval = null;
· Comprobamos que el temporizador está activo y que además timeLeft
es mayor a 1. Es decir, que aún queda tiempo que descontar. Si ambas condiciones se cumplen calcularemos el tiempo que queda y le sumaremos un segundo a timeSpent
. Lo haremos además asignando todo esto a un intervalo para que el cambio se produzca tras 1 segundo. Y como este efecto está escuchando a timeSpent
, se seguirá ejecutando hasta que isActive
cambie y pase a ser false. Cuando esto ocurra, limpiaremos el intervalo.
if (isActive && timeLeft > 1) {
setTimeLeft(
mode == "session"
? sessionLength * 1000 - timeSpent
: breakLength * 1000 - timeSpent
);
interval = setInterval(() => {
setTimeSpent((timeSpent) => timeSpent + 1000);
}, 1000);
} else {
clearInterval(interval);
}
Ahora solamente necesitamos saber cuándo timeLeft
ha llegado a cero, que será el momento de hacer que nuestro beep
se reproduzca. También cambiamos el modo y pasamos la longitud del que corresponda a timeLeft
para que el temporizador siga corriendo:
if (timeLeft === 0) {
beep.play();
setBeepPlaying(true);
setTimeSpent(0);
setMode((mode) => (mode == "session" ? "break" : "session"));
setTimeLeft(
mode == "session" ? sessionLength * 1000 : breakLength * 1000
);
}
Aquí te dejo el código completo:
useEffect(() => {
let interval = null;
if (isActive && timeLeft > 1) {
setTimeLeft(
mode == "session"
? sessionLength * 1000 - timeSpent
: breakLength * 1000 - timeSpent
);
interval = setInterval(() => {
setTimeSpent((timeSpent) => timeSpent + 1000);
}, 1000);
} else {
clearInterval(interval);
}
if (timeLeft === 0) {
beep.play();
setBeepPlaying(true);
setTimeSpent(0);
setMode((mode) => (mode == "session" ? "break" : "session"));
setTimeLeft(
mode == "session" ? sessionLength * 1000 : breakLength * 1000
);
}
return () => clearInterval(interval);
}, [isActive, timeSpent]);
¡Uf! Coge aire y bebe un poco de agua. Ya queda menos.
Este useEffect solamente va a encargarse de poner beepPlaying
a false cuando beep
deje de sonar.
useEffect(() => {
beep.addEventListener("ended", () => setBeepPlaying(false));
return () => {
beep.addEventListener("ended", () => setBeepPlaying(false));
};
}, []);
Solo falta añadir algunas funciones a nuestro componente para que funcione como necesitamos:
function reset() {
setBreakLength(5 * 60);
setSessionLength(25 * 60);
setTimeLeft(mode == "session" ? sessionLength * 1000 : breakLength * 1000);
if (isActive) {
setIsActive(false);
setTimeSpent(0);
}
if (beepPlaying) {
beep.pause();
beep.currentTime = 0;
setBeepPlaying(false);
}
}
function toggleIsActive() {
setIsActive(!isActive);
}
reset
es un poco larga pero lo único que hace es restaurar los valores de breakLength
y sessionLength
a 5 y 25 minutos respectivamente. Además detiene el temporizador poniendo isActive
a false y lo reinicia poniendo en cero la variable timeSpent
.
toggleIsActive
simplemente cambia isActive
al valor opuesto (de false a true y viceversa).
Y ya solo nos queda añadir un par de componentes Button para que todo esto pueda funcionar.
<Container className="text-center">
<h1>Pomodoro Clock</h1>
<Timer time={timeLeft} mode={mode} />
<div className="buttons">
<Button onClick={toggleIsActive} id="start_stop">
{isActive ? "Pause" : "Start"}
</Button>
<Button onClick={reset} id="reset" variant="danger">
Reset
</Button>
</div>
<Row className="options">
<Break
length={breakLength}
decrement={decrementBreakLength}
increment={incrementBreakLength}
/>
<Session
length={sessionLength}
decrement={decrementSessionLength}
increment={incrementSessionLength}
/>
</Row>
</Container>
Puedes ver que hemos añadido dos botones: uno para poner el temporizador en marcha (o pausarlo) y otro para poder resetearlo.
Pero espera, porque aunque nuestro temporizador funciona aún no estamos mostrando nada en pantalla. Timer sigue con valores estáticos. Vamos a solucionarlo:
const Timer = (props) => {
const { time, mode } = props;
const min = Math.floor(time / 1000 / 60);
const sec = Math.floor((time / 1000) % 60);
return (
<div id="timer">
<p id="timer-label">{mode}</p>
<p id="time-left">
{min}:{sec.toString().length === 1 ? "0" + sec : sec}
</p>
</div>
);
};
Simplemente vamos a coger el modo en el que estamos y el tiempo que queda de las propiedades. Para mostrar el tiempo necesitamos calcular los minutos y segundos, y luego poder añadir un cero en caso de que la cifra de segundos solo tenga un dígito.
¡Y ya está! Ya tienes tu Pomodoro.
const Break = (props) => {
const { increment, decrement, length } = props;
return (
<Col md={4}>
<p id="break-label">Break</p>
<Button onClick={decrement} id="break-decrement" variant="info">
-
</Button>
<span id="break-length">{length / 60}</span>
<Button variant="info" onClick={increment} id="break-increment">
+
</Button>
</Col>
);
};
const Session = (props) => {
const { increment, decrement, length } = props;
return (
<Col md={{ span: 4, offset: 4 }}>
<p id="session-label">Session</p>
<Button onClick={decrement} id="session-decrement" variant="info">
-
</Button>
<span id="session-length">{length / 60}</span>
<Button onClick={increment} id="session-increment" variant="info">
+
</Button>
</Col>
);
};
const Timer = (props) => {
const { time, mode } = props;
const min = Math.floor(time / 1000 / 60);
const sec = Math.floor((time / 1000) % 60);
return (
<div id="timer">
<p id="timer-label">{mode}</p>
<p id="time-left">
{min}:{sec.toString().length === 1 ? "0" + sec : sec}
</p>
</div>
);
};
const App = () => {
const [breakLength, setBreakLength] = React.useState(5 * 60);
const [sessionLength, setSessionLength] = React.useState(25 * 60);
const [mode, setMode] = React.useState("session");
const [timeLeft, setTimeLeft] = React.useState();
const [isActive, setIsActive] = React.useState(false);
const [timeSpent, setTimeSpent] = React.useState(0);
const [beep] = React.useState(
new Audio("https://freesound.org/data/previews/523/523960_350703-lq.mp3")
);
const [beepPlaying, setBeepPlaying] = React.useState(false);
/* ########## USE EFFECT HOOKS ########## */
React.useEffect(() => {
setTimeLeft(mode == "session" ? sessionLength * 1000 : breakLength * 1000);
}, [sessionLength, breakLength]);
React.useEffect(() => {
let interval = null;
if (isActive && timeLeft > 1) {
setTimeLeft(
mode == "session"
? sessionLength * 1000 - timeSpent
: breakLength * 1000 - timeSpent
);
interval = setInterval(() => {
setTimeSpent((timeSpent) => timeSpent + 1000);
}, 1000);
} else {
clearInterval(interval);
}
if (timeLeft === 0) {
beep.play();
setBeepPlaying(true);
setTimeSpent(0);
setMode((mode) => (mode == "session" ? "break" : "session"));
setTimeLeft(
mode == "session" ? sessionLength * 1000 : breakLength * 1000
);
}
return () => clearInterval(interval);
}, [isActive, timeSpent]);
React.useEffect(() => {
beep.addEventListener("ended", () => setBeepPlaying(false));
return () => {
beep.addEventListener("ended", () => setBeepPlaying(false));
};
}, []);
/* ########## FUNCTIONS ########## */
function decrementBreakLength() {
const decreasedBreakLength = breakLength - 60 > 60 ? breakLength - 60 : 60;
setBreakLength(decreasedBreakLength);
}
function incrementBreakLength() {
const incrementedBreakLength =
breakLength + 60 <= 60 * 60 ? breakLength + 60 : 60 * 60;
setBreakLength(incrementedBreakLength);
}
function decrementSessionLength() {
const decreasedSessionLength =
sessionLength - 60 > 60 ? sessionLength - 60 : 60;
setSessionLength(decreasedSessionLength);
}
function incrementSessionLength() {
const incrementedSessionLength =
sessionLength + 60 <= 60 * 60 ? sessionLength + 60 : 60;
setSessionLength(incrementedSessionLength);
}
function reset() {
setBreakLength(5 * 60);
setSessionLength(25 * 60);
setTimeLeft(mode == "session" ? sessionLength * 1000 : breakLength * 1000);
if (isActive) {
setIsActive(false);
setTimeSpent(0);
}
if (beepPlaying) {
beep.pause();
beep.currentTime = 0;
setBeepPlaying(false);
}
}
function toggleIsActive() {
setIsActive(!isActive);
}
return (
<Container className="text-center">
<h1>Pomodoro Clock</h1>
<Timer time={timeLeft} mode={mode} />
<div className="buttons">
<Button onClick={toggleIsActive} id="start_stop">
{isActive ? "Pause" : "Start"}
</Button>
<Button onClick={reset} id="reset" variant="danger">
Reset
</Button>
</div>
<Row className="options">
<Break
length={breakLength}
decrement={decrementBreakLength}
increment={incrementBreakLength}
/>
<Session
length={sessionLength}
decrement={decrementSessionLength}
increment={incrementSessionLength}
/>
</Row>
</Container>
);
};
Ahora puedes trastear con él, como por ejemplo:
- Cambiar el sonido o dar a elegir entre varios diferentes
- Añadir otra forma de notificar el cambio de modo, ya que tal y como está ahora no podría ser usado por personas sordas. A mí se me ocurre, por ejemplo, cambiar el de la página dinámicamente, pero no he visto que sea posible en Codepen.
- Fusionar tu Pomodoro con un proyecto de To-do list, de manera que puedas planificar en qué vas a invertir las sesiones.
Te dejo aquí de nuevo mi proyecto terminado y espero que hayas disfrutado construyendo esta pequeña aplicación.
Top comments (3)
Pense que tenia el traductor de pagina activado otra vez jajajja, pero me parecio genial el blog y ya que recientemente comence a usar la tecnica de pomodoro para organizar mi tiempo :)
¡me alegro mucho! Vi que ya había varios tutoriales en inglés para hacer Pomodoros y pensé que así sería más útil.
Muchas gracias por tu comentario y espero que te acuerdes de pasar por aquí el link al tuyo si lo haces.
Yo también lo estoy usando últimamente. ¿qué tal? ¿Te funciona? Confieso que ahora con la situación actual me cuesta más concentrarme y he subido el nivel. Ahora lo que pongo es gente en Youtube que se pone Pomodoros. insertar gif de Inception aquí
Te felicito Logan, sigamos compartiendo contenido en español. Aunque sepamos inglés, es bueno crear contenido en nuestra lengua natal.