Cómo crear una aplicación de tareas pendientes con JavaScript puro (y almacenamiento local)
() translation by (you can also view the original English article)
En este tutorial, mejoraremos nuestras habilidades en el front-end aprendiendo a crear una aplicación de tareas pendientes "hecha a mano". Para crearla no aprovecharemos ningún framework de JavaScript; solamente usaremos HTML, CSS y JavaScript puro.
Lo que vamos a construir
Este es un video introductorio que demuestra la funcionalidad de la aplicación JavaScript que vamos a crear. Los usuarios podrán agregar tareas, marcarlas como terminadas y eliminarlas. Los totales de las tareas y sus estados se mostrarán en la barra de estado:
Aquí está la demostración en Codepen para que la bifurques y juegues con ella:
Nota: este no es un tutorial introductorio. Este asume que estás familiarizado con habilidades esenciales del front-end como CSS Grid, flexbox, ES6, arreglos de JavaScript, etc. Además, para esta demostración, hacer que la aplicación sea totalmente accesible no es una prioridad.
1. Comienza con los recursos necesarios
Para que el diseño sea un poco más único, usaremos algunas ilustraciones SVG hechas a mano y una fuente personalizada tomada de Envato Elements.



Vale la pena señalar que la mayoría de estos recursos provendrán de un tutorial anterior. De hecho, también usaremos muchas de las técnicas de posicionamiento que aprendimos en este tutorial, así que vale la pena leerlo.



2. Continúa con el marcado de la página
Comenzaremos con un SVG y un contenedor div
:
1 |
<svg style="display:none;">...</svg> |
2 |
<div class="container">...</div> |
Sprites de SVG
Como lo hemos hecho muchas veces en el pasado, y como una práctica recomendada, vamos a almacenar todos los SVGs como symbol
s en un contenedor de tipo sprite de SVG. Después, los desplegaremos en la pantalla cada vez que sea necesario llamando al elemento use
.
Este es el marcado para el sprite de SVG:
1 |
<svg style="display:none;"> |
2 |
<symbol id="input" viewBox="0 0 345.27 56.51" preserveAspectRatio="none">...</symbol> |
3 |
<symbol id="checkbox_empty" viewBox="0 0 33.18 33.34">...</symbol> |
4 |
<symbol id="checkmark" viewBox="0 0 37.92 33.3" preserveAspectRatio="none">...</symbol> |
5 |
<symbol id="button" viewBox="0 0 256.6 60.02" preserveAspectRatio="none">...</symbol> |
6 |
<symbol id="close" viewBox="0 0 29.71 30.59">...</symbol> |
7 |
<symbol id="stats" viewBox="0 0 998.06 602.62" preserveAspectRatio="none">...</symbol> |
8 |
</svg>
|
Observa el atributo preserveAspectRatio="none"
que adjuntamos a la mayoría de las ilustraciones. Hemos hecho esto porque, como veremos más adelante, nuestros iconos cambiarán de escala y perderán sus dimensiones iniciales.
Contenedor
El contenedor incluirá un formulario, un elemento div
y una lista ordenada vacía:
1 |
<div class="container"> |
2 |
<form class="todo-form">...</form> |
3 |
<div class="todo-stats">...</div> |
4 |
<ol class="todo-list"></ol> |
5 |
</div>
|
Dentro del formulario, tendremos un campo de entrada y un botón de envío junto con sus SVGs asociados:
1 |
<form class="todo-form"> |
2 |
<div class="form-wrapper"> |
3 |
<input type="text" name="name" autofocus> |
4 |
<svg>
|
5 |
<use xlink:href="#input"></use> |
6 |
</svg>
|
7 |
</div>
|
8 |
<div class="form-wrapper"> |
9 |
<button type="submit">Add new task</button> |
10 |
<svg>
|
11 |
<use xlink:href="#button"></use> |
12 |
</svg>
|
13 |
</div>
|
14 |
</form>
|
Observa el atributo name
que hemos agregado al campo de entrada. Más adelante usaremos este atributo para acceder al valor de la entrada después del envío del formulario.
Nota: en nuestra demostración, el atributo autofocus
del campo de texto no funcionará. De hecho mostrará el siguiente error, que puedes ver si abres la consola de tu navegador:



Sin embargo, si ejecutas esta aplicación localmente (no como un proyecto de Codepen), este problema no existirá. Alternativamente, puedes establecer el foco a través de JavaScript.
Dentro del div
, colocaremos tres div
s anidados y el SVG asociado. En esta sección llevaremos a cabo un seguimiento de la cantidad total de tareas (tanto restantes como terminadas):
1 |
<div class="todo-stats"> |
2 |
<div class="total-tasks"> |
3 |
Total Tasks: <span>0</span> |
4 |
</div>
|
5 |
<div class="completed-tasks"> |
6 |
Completed Tasks: <span>0</span> |
7 |
</div>
|
8 |
<div class="remaining-tasks"> |
9 |
Remaining Tasks: <span>0</span> |
10 |
</div>
|
11 |
<svg>
|
12 |
<use xlink:href="#stats"></use> |
13 |
</svg>
|
14 |
</div>
|
Finalmente, los elementos de la lista ordenada serán agregados de forma dinámica a través de JavaScript.
3. Define algunos estilos básicos
Con el marcado listo, continuaremos con algunos estilos de restablecimiento:
1 |
@font-face { |
2 |
font-family: "Summer"; |
3 |
src: url(SummerFont-Regular.woff); |
4 |
}
|
5 |
|
6 |
@font-face { |
7 |
font-family: "Summer Bold"; |
8 |
src: url(SummerFont-Bold.woff); |
9 |
}
|
10 |
|
11 |
:root { |
12 |
--white: #fff; |
13 |
}
|
14 |
|
15 |
* { |
16 |
padding: 0; |
17 |
margin: 0; |
18 |
border: none; |
19 |
outline: none; |
20 |
box-sizing: border-box; |
21 |
}
|
22 |
|
23 |
input, |
24 |
button { |
25 |
font-family: inherit; |
26 |
font-size: 100%; |
27 |
background: none; |
28 |
}
|
29 |
|
30 |
[type="checkbox"] { |
31 |
position: absolute; |
32 |
left: -9999px; |
33 |
}
|
34 |
|
35 |
button, |
36 |
label { |
37 |
cursor: pointer; |
38 |
}
|
39 |
|
40 |
ol { |
41 |
list-style: none; |
42 |
}
|
43 |
|
44 |
body { |
45 |
font: 28px/1.2 "Summer"; |
46 |
margin: 1.5rem 0; |
47 |
}
|
4. Establece los estilos principales
Ahora discutamos los estilos principales de nuestra aplicación.
Estilos del contenedor
El contenedor tendrá un ancho máximo, y el contenido estará centrado horizontalmente:
1 |
.container { |
2 |
max-width: 700px; |
3 |
padding: 0 10px; |
4 |
margin: 0 auto; |
5 |
}
|
Estilos del formulario
En pantallas pequeñas, todos los elementos del formulario se apilarán:



Sin embargo, en las ventanas de 600 píxeles de ancho o más, el diseño del formulario cambiará de la siguiente manera:



Tomemos nota de dos cosas:
- En las ventanas anchas, el tamaño del campo de entrada será el doble del tamaño del botón.
- Los SVGs serán elementos con posiciones absolutas y se ubicarán debajo de sus controles adyacentes en el formulario. Nuevamente, para obtener una explicación más detallada, echa un vistazo a este tutorial anterior.
Estos son los estilos para esta sección:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.todo-form .form-wrapper { |
4 |
position: relative; |
5 |
}
|
6 |
|
7 |
.todo-form input, |
8 |
.todo-form button { |
9 |
position: relative; |
10 |
width: 100%; |
11 |
z-index: 1; |
12 |
padding: 15px; |
13 |
}
|
14 |
|
15 |
.todo-form svg { |
16 |
position: absolute; |
17 |
top: 0; |
18 |
left: 0; |
19 |
width: 100%; |
20 |
height: 100%; |
21 |
}
|
22 |
|
23 |
.todo-form button { |
24 |
color: var(--white); |
25 |
text-transform: uppercase; |
26 |
}
|
27 |
|
28 |
@media screen and (min-width: 600px) { |
29 |
.todo-form { |
30 |
display: grid; |
31 |
grid-template-columns: 2fr 1fr; |
32 |
grid-column-gap: 5px; |
33 |
}
|
34 |
}
|
Estilos de las estadísticas
A continuación veamos la barra de estado, que nos dará un informe rápido sobre la cantidad total de tareas.
En pantallas pequeñas tendrá la siguiente apariencia apilada:



Sin embargo, en ventanas de 600 píxeles de ancho o más debería cambiar de la siguiente forma:



Tomemos nota de dos cosas:
- En las ventanas anchas, todos los elementos
div
hijos tendrán el mismo ancho. - De manera similar a los SVGs anteriores, este también tendrá una posición absoluta y actuará como una imagen de fondo que cubre toda la sección.
Los estilos relacionados:
1 |
/*CUSTOM VARIABLES HERE*/
|
2 |
|
3 |
.todo-stats { |
4 |
position: relative; |
5 |
text-align: center; |
6 |
padding: 5px 10px; |
7 |
margin: 10px 0; |
8 |
color: var(--white); |
9 |
}
|
10 |
|
11 |
.todo-stats > div { |
12 |
position: relative; |
13 |
z-index: 1; |
14 |
}
|
15 |
|
16 |
.todo-stats svg { |
17 |
position: absolute; |
18 |
top: 0; |
19 |
left: 0; |
20 |
width: 100%; |
21 |
height: 100%; |
22 |
}
|
23 |
|
24 |
@media screen and (min-width: 600px) { |
25 |
.todo-stats { |
26 |
display: grid; |
27 |
grid-template-columns: repeat(3, 1fr); |
28 |
}
|
29 |
}
|
Estilos de las tareas
El diseño de las tareas, que generaremos de forma dinámica en la próxima sección, se verá de esta forma:



Cada tarea, que se representará por un li
, tendrá dos partes.



En la primera parte verás una casilla de verificación junto con el nombre de la tarea. En la segunda parte verás un botón de eliminación para borrar la tarea.
Estos son los estilos relacionados:
1 |
.todo-list li { |
2 |
display: grid; |
3 |
align-items: baseline; |
4 |
grid-template-columns: auto 20px; |
5 |
grid-column-gap: 10px; |
6 |
padding: 0 10px; |
7 |
}
|
8 |
|
9 |
.todo-list li + li { |
10 |
margin-top: 10px; |
11 |
}
|
Cuando una tarea está incompleta, aparecerá una casilla de verificación vacía. Por otro lado, si una tarea está marcada como terminada, aparecerá una marca de verificación. De manera adicional, su nombre adquirirá una opacidad del 50% además de una línea atravesándolo.
Estos son los estilos responsables de este comportamiento:
1 |
.todo-list .checkbox-wrapper { |
2 |
display: flex; |
3 |
align-items: baseline; |
4 |
}
|
5 |
|
6 |
.todo-list .checkbox-wrapper label { |
7 |
display: grid; |
8 |
margin-right: 10px; |
9 |
}
|
10 |
|
11 |
.todo-list .checkbox-wrapper svg { |
12 |
grid-column: 1; |
13 |
grid-row: 1; |
14 |
width: 20px; |
15 |
height: 20px; |
16 |
}
|
17 |
|
18 |
.todo-list .checkbox-wrapper .checkmark { |
19 |
display: none; |
20 |
}
|
21 |
|
22 |
.todo-list [type="checkbox"]:checked + label .checkmark { |
23 |
display: block; |
24 |
}
|
25 |
|
26 |
.todo-list [type="checkbox"]:checked ~ span { |
27 |
text-decoration: line-through; |
28 |
opacity: 0.5; |
29 |
}
|
Finalmente, a continuación puedes ver los estilos del botón para eliminar:
1 |
.todo-list .remove-task { |
2 |
display: flex; |
3 |
padding: 2px; |
4 |
}
|
5 |
|
6 |
.todo-list .remove-task svg { |
7 |
width: 16px; |
8 |
height: 16px; |
9 |
}
|
5. Agrega el código JavaScript
En este punto, estamos listos para crear la funcionalidad principal de nuestra aplicación. ¡Hagámoslo!
Al enviar el formulario
Cada vez que un usuario envíe el formulario, ya sea presionando la tecla Enter o el botón Submit (Enviar), haremos lo siguiente:
- Detendremos el envío del formulario, para evitar así una recarga de la página.
- Tomaremos el valor contenido en el campo de entrada.
- Suponiendo que el campo de entrada no está vacío, crearemos un nuevo literal de objeto que representará a la tarea. Cada tarea tendrá un id único, un nombre, y estará activa (no terminada) de forma predeterminada.
- Agregaremos esta tarea al arreglo
tasks
. - Guardaremos el arreglo en el almacenamiento local. El almacenamiento local solamente admite cadenas, por lo que, para hacer eso, tenemos que usar el método
JSON.stringify()
para convertir los objetos del interior del arreglo en cadenas. - Llamaremos a la función
createTask()
para representar visualmente la tarea en la pantalla. - Limpiaremos el formulario.
- Asignaremos el foco al campo de entrada.
Este es el código correspondiente:
1 |
const todoForm = document.querySelector(".todo-form"); |
2 |
let tasks = []; |
3 |
|
4 |
todoForm.addEventListener("submit", function(e) { |
5 |
// 1
|
6 |
e.preventDefault(); |
7 |
// 2
|
8 |
const input = this.name; |
9 |
const inputValue = input.value; |
10 |
|
11 |
if (inputValue != "") { |
12 |
// 3
|
13 |
const task = { |
14 |
id: new Date().getTime(), |
15 |
name: inputValue, |
16 |
isCompleted: false |
17 |
};
|
18 |
// 4
|
19 |
tasks.push(task); |
20 |
// 5
|
21 |
localStorage.setItem("tasks", JSON.stringify(tasks)); |
22 |
// 6
|
23 |
createTask(task); |
24 |
// 7
|
25 |
todoForm.reset(); |
26 |
}
|
27 |
// 8
|
28 |
input.focus(); |
29 |
});
|
Crea una tarea
La función createTask()
será responsable de la creación del marcado de la tarea.
Por ejemplo, esta es la estructura de la tarea "Salir a caminar":



Aquí hay dos cosas importantes:
- Si la tarea ha sido terminada, aparecerá la marca de verificación.
- Si la tarea no ha sido terminada, su elemento
span
recibirá el atributocontenteditable
. Este atributo nos dará la posibilidad de editar o actualizar su nombre.
A continuación se muestra la sintaxis de esta función:
1 |
function createTask(task) { |
2 |
const taskEl = document.createElement("li"); |
3 |
taskEl.setAttribute("id", task.id); |
4 |
const taskElMarkup = ` |
5 |
<div class="checkbox-wrapper">
|
6 |
<input type="checkbox" id="${task.name}-${task.id}" name="tasks" ${ |
7 |
task.isCompleted ? "checked" : "" |
8 |
}> |
9 |
<label for="${task.name}-${task.id}"> |
10 |
<svg class="checkbox-empty">
|
11 |
<use xlink:href="#checkbox_empty"></use>
|
12 |
</svg>
|
13 |
<svg class="checkmark">
|
14 |
<use xlink:href="#checkmark"></use>
|
15 |
</svg>
|
16 |
</label>
|
17 |
<span ${!task.isCompleted ? "contenteditable" : ""}>${task.name}</span> |
18 |
</div>
|
19 |
<button class="remove-task" title="Remove ${task.name} task"> |
20 |
<svg>
|
21 |
<use xlink:href="#close"></use>
|
22 |
</svg>
|
23 |
</button>
|
24 |
`; |
25 |
taskEl.innerHTML = taskElMarkup; |
26 |
todoList.appendChild(taskEl); |
27 |
countTasks(); |
28 |
}
|
Actualiza una tarea
Una tarea puede actualizarse de dos formas diferentes:
- Cambiando su estado de "no terminada" a "terminada" y viceversa.
- Modificando su nombre en caso de que la tarea no haya sido terminada. Recuerda que, en este caso, el elemento
span
tiene el atributocontenteditable
.
Para hacer un seguimiento de estos cambios aprovecharemos el evento input
. Este es un evento aceptable para nosotros, ya que se aplica tanto a los elementos input
como a los elementos con contenteditable
habilitado.
Lo complicado es que no podemos adjuntar este evento directamente a los elementos destino (casilla de verificación, span
), porque se crean de forma dinámica y no son parte del DOM al cargar la página.
Gracias a la delegación de eventos, adjuntaremos el evento input
a la lista padre, que es parte del marcado inicial. Después, a través de la propiedad target
de ese evento, revisaremos los elementos en los que ocurrió el evento y llamaremos a la función updateTask()
:
1 |
todoList.addEventListener("input", (e) => { |
2 |
const taskId = e.target.closest("li").id; |
3 |
updateTask(taskId, e.target); |
4 |
});
|
Dentro de la función updateTask()
haremos lo siguiente:
- Tomaremos la tarea que necesita actualizarse.
- Revisaremos el elemento que desencadenó el evento. Si el elemento tiene el atributo
contenteditable
(es decir, es el elementospan
), estableceremos el nombre de la tarea para que sea igual al contenido de texto despan
. - De lo contrario (es decir, se trata de la casilla de verificación), cambiaremos el estado de la tarea y su atributo
checked
. Además, también cambiaremos el atributocontenteditable
delspan
adyacente. - Actualizaremos el valor de la clave
tasks
en el almacenamiento local. - Llamaremos a la función
countTasks()
.
A continuación puedes ver la sintaxis de esta función:
1 |
function updateTask(taskId, el) { |
2 |
// 1
|
3 |
const task = tasks.find((task) => task.id === parseInt(taskId)); |
4 |
|
5 |
if (el.hasAttribute("contentEditable")) { |
6 |
// 2
|
7 |
task.name = el.textContent; |
8 |
} else { |
9 |
// 3
|
10 |
const span = el.nextElementSibling.nextElementSibling; |
11 |
task.isCompleted = !task.isCompleted; |
12 |
if (task.isCompleted) { |
13 |
span.removeAttribute("contenteditable"); |
14 |
el.setAttribute("checked", ""); |
15 |
} else { |
16 |
el.removeAttribute("checked"); |
17 |
span.setAttribute("contenteditable", ""); |
18 |
}
|
19 |
}
|
20 |
// 4
|
21 |
localStorage.setItem("tasks", JSON.stringify(tasks)); |
22 |
// 5
|
23 |
countTasks(); |
24 |
}
|
Elimina una tarea
Podemos eliminar una tarea a través del botón "cerrar".



De manera similar a la operación de actualización, no podemos adjuntar un evento directamente a este botón, ya que no se encuentra en el DOM al cargar la página.
Gracias a la delegación de eventos de nuevo, adjuntaremos un evento click
a la lista padre y llevaremos a cabo las siguientes acciones:
- Revisaremos si el elemento al que se le hizo clic es el botón "cerrar" o su SVG hijo.
- Si eso ocurre, obtendremos el
id
del elemento de la lista padre. - Enviaremos este
id
a la funciónremoveTask()
.
Este es el código correspondiente:
1 |
const todoList = document.querySelector(".todo-list"); |
2 |
|
3 |
todoList.addEventListener("click", (e) => { |
4 |
// 1
|
5 |
if ( |
6 |
e.target.classList.contains("remove-task") || |
7 |
e.target.parentElement.classList.contains("remove-task") |
8 |
) { |
9 |
// 2
|
10 |
const taskId = e.target.closest("li").id; |
11 |
// 3
|
12 |
removeTask(taskId); |
13 |
}
|
14 |
});
|
Dentro de la función removeTask()
haremos lo siguiente:
- Eliminaremos la tarea asociada del arreglo
tasks
. - Actualizaremos el valor de la clave
tasks
en el almacenamiento local. - Eliminaremos el elemento asociado de la lista.
- Llamaremos a la función
countTasks()
.
Aquí puedes ver la sintaxis de esta función:
1 |
function removeTask(taskId) { |
2 |
// 1
|
3 |
tasks = tasks.filter((task) => task.id !== parseInt(taskId)); |
4 |
// 2
|
5 |
localStorage.setItem("tasks", JSON.stringify(tasks)); |
6 |
// 3
|
7 |
document.getElementById(taskId).remove(); |
8 |
// 4
|
9 |
countTasks(); |
10 |
}
|
Cuenta las tareas
Como ya hemos discutido, muchas de las funciones anteriores incluyen a la función countTask()
.
Su trabajo es monitorear las tareas en busca de cambios (adiciones, actualizaciones, eliminaciones) y actualizar el contenido de los elementos relacionados.



Esta es su firma:
1 |
const totalTasks = document.querySelector(".total-tasks span"); |
2 |
const completedTasks = document.querySelector(".completed-tasks span"); |
3 |
const remainingTasks = document.querySelector(".remaining-tasks span"); |
4 |
|
5 |
function countTasks() { |
6 |
totalTasks.textContent = tasks.length; |
7 |
const completedTasksArray = tasks.filter((task) => task.isCompleted === true); |
8 |
completedTasks.textContent = completedTasksArray.length; |
9 |
remainingTasks.textContent = tasks.length - completedTasksArray.length; |
10 |
}
|
Evita la adición de líneas nuevas
Cada vez que un usuario actualice el nombre de una tarea, no debería poder crear líneas nuevas presionando la tecla Enter.



Para deshabilitar esta funcionalidad, una vez más aprovecharemos la delegación de eventos y adjuntaremos el evento keydown
a la lista, de esta forma:
1 |
todoList.addEventListener("keydown", function (e) { |
2 |
if (e.keyCode === 13) { |
3 |
e.preventDefault(); |
4 |
}
|
5 |
});
|
Observa que, en este escenario, solamente los elementos span
podrían desencadenar ese evento, por lo que no hay necesidad de realizar una verificación adicional de esta manera:
1 |
if (e.target.hasAttribute("contenteditable") && e.keyCode === 13) { |
2 |
e.preventDefault(); |
3 |
}
|
Conserva los datos al cargar la página
Hasta ahora, si cerramos el navegador y nos dirigimos al proyecto de demostración, nuestras tareas desaparecerán.
Pero espera, ¡eso no es 100% cierto! Recuerda que cada vez que manipulamos una tarea, también almacenamos el arreglo tasks
en el almacenamiento local. Por ejemplo, en Chrome, para ver las claves y valores del almacenamiento local haz clic en la pestaña Application (Aplicación), luego expande el menú Local Storage (Almacenamiento local) y, finalmente, haz clic en un dominio para ver sus pares de clave y valor.
En mi caso, estos son los valores para la clave tasks
:



Entonces, para mostrar estas tareas primero necesitamos recuperarlas del almacenamiento local. Para hacer esto usaremos el método JSON.parse()
, que convertirá las cadenas de vuelta a objetos de JavaScript.
A continuación almacenaremos todas las tareas en el familiar arreglo tasks
. Ten en cuenta que, si no hay datos en el almacenamiento local (por ejemplo, al visitar la aplicación por primera vez), este arreglo está vacío. Después tenemos qué recorrer el arreglo y, por cada tarea, debemos llamar a la función createTask()
. ¡Y eso es todo!
El fragmento de código correspondiente:
1 |
let tasks = JSON.parse(localStorage.getItem("tasks")) || []; |
2 |
|
3 |
if (localStorage.getItem("tasks")) { |
4 |
tasks.map((task) => { |
5 |
createTask(task); |
6 |
});
|
7 |
}
|
Conclusión
¡Uf! Gracias por seguir los pasos en este largo viaje, amigos. Con suerte, hoy has adquirido nuevos conocimientos que podrás aplicar a tus propios proyectos.
Recordemos lo que hemos creado:
Sin duda, crear una aplicación así con un framework de JavaScript podría ser más estable, sencillo y eficiente (volver a pintar el DOM es caro). Sin embargo, saber resolver este tipo de ejercicio con JavaScript puro te ayudará a obtener una comprensión sólida de sus fundamentos y te convertirá en un mejor desarrollador de JavaScript.
Antes de terminar, permíteme proponer dos ideas para extender este ejercicio:
- Usa la API HTML para arrastrar y soltar o una biblioteca de JavaScript como Sortable.js para reordenar las tareas.
- Almacena los datos (las tareas) en la nube en vez de hacerlo en el navegador. Por ejemplo, sustituye el almacenamiento local con una base de datos en tiempo real como Firebase.
Como siempre, ¡muchas gracias por leer!
Más aplicaciones con JavaScript puro
Si deseas aprender a crear pequeñas aplicaciones con JavaScript puro, consulta los siguientes tutoriales: