Build a Todo App
Let’s create a feature-rich todo application that will teach you advanced HellaJS patterns through hands-on development.
Prerequisites: Complete the Counter App Tutorial first to understand signals, computed values, and effects.
What You’ll Learn
This tutorial progressively builds a complete todo application, teaching you.
- Complex state management - Managing multiple related pieces of state
- Array manipulation - Working with lists of data reactively
- Form handling - Capturing and validating user input
- Conditional rendering - Showing different UI based on state
- Computed filtering - Deriving filtered views from data
- Inline editing - Advanced interaction patterns
- Data persistence - Saving state across browser sessions
- Effects for side actions - Document title updates and more
We’ll start simple and add complexity step by step, so you see how each concept builds on the previous ones.
Project Setup
Installation
Use your favorite package manager to scaffold a new Vite project.
npm create vite@latest todo-app -- --template vanilla
Navigate into your new project directory and install the necessary HellaJS packages, the Vite plugin, and Tailwind CSS v4.
cd todo-app
npm install @hellajs/core @hellajs/dom @hellajs/store
npm install -D vite-plugin-hellajs @tailwindcss/vite@next
Configuration
Update your vite.config.js
to use the HellaJS plugin and Tailwind CSS.
// vite.config.js
import { defineConfig } from 'vite';
import viteHellaJS from 'vite-plugin-hellajs';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
plugins: [viteHellaJS(), tailwindcss()],
});
Create a style.css
file in your src
directory and import Tailwind.
/* src/style.css */
@import "tailwindcss";
Import this CSS file in your main.js
.
import './style.css';
If you’re using TypeScript, you can add type definitions for HellaJS. Update your tsconfig.json
.
{
"compilerOptions": {
//...
"jsx": "preserve",
"types": ["@hellajs/dom"]
//...
}
}
Basic Todo List
Let’s start with the foundation, a simple todo list that can add, toggle, and delete items.
Replace the content of main.js
with the basic todo app.
// main.js
import { signal } from "@hellajs/core";
import { mount, forEach } from "@hellajs/dom";
import './style.css';
const TodoApp = () => {
const todos = signal([
{ id: 1, text: 'Learn HellaJS', completed: false },
{ id: 2, text: 'Build a todo app', completed: false }
]);
const newTodoText = signal('');
const addTodo = () => {
const text = newTodoText().trim();
if (!text) return;
todos([
...todos(),
{ id: Date.now(), text, completed: false }
]);
newTodoText('');
};
const toggleTodo = (id) => {
todos(todos().map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const removeTodo = (id) => {
todos(todos().filter(todo => todo.id !== id));
};
return (
<div class="p-4 max-w-md mx-auto">
<h1 class="text-2xl mb-4 text-center">Todo App</h1>
<div class="flex gap-2 mb-4">
<input
type="text"
value={newTodoText}
onInput={e => newTodoText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
class="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={addTodo}
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Add
</button>
</div>
<ul class="space-y-2">
{forEach(todos, todo => (
<li key={todo.id} class="flex items-center gap-2 p-2 border rounded">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span class={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.text}
</span>
<button
onClick={() => removeTodo(todo.id)}
class="px-2 py-1 bg-red-500 text-white rounded text-sm"
>
Delete
</button>
</li>
))}
</ul>
{todos().length === 0 && (
<div class="text-center text-gray-500 mt-4">
No todos yet! Add one above.
</div>
)}
</div>
);
};
mount(TodoApp, '#app');
Start the development server to see your app in action.
npm run dev
Your app will be running at http://localhost:5173
. Add some todos and watch them update automatically!
Code Explanation
const todos = signal([...])
- Creates reactive state for our todo list with initial dataconst newTodoText = signal('')
- Creates reactive state for the new todo input field{newTodoText}
- Creates reactive two-way binding for the input fieldforEach(todos, todo => (...))
- Renders the todo list with optimized list diffing- Array immutability - Using spread operator
[...todos(), newTodo]
for reactive updates - Event handlers -
onClick
,onInput
, andonKeyDown
for user interactions - Conditional rendering -
{todos().length === 0 && (...)}
shows empty state mount(TodoApp, '#app')
- Renders the component into the DOM
✅ Good: Creates a new array
todos([...todos(), newTodo]);
⛔ Bad: Mutates an existing array
todos().push(newTodo); // Won't trigger updates
forEach
for signal list or they wont be reactive.✅ Good: Reactive lists
forEach(todos, todo => (
<li key={todo.id}>
{todo.text}
</li>
));
⛔ Bad: Only rendered once
todos().map(todo => (
<li key={todo.id}>
{todo.text}
</li>
));
Filtering and Statistics
Let’s add filtering and live statistics that update automatically using computed values.
import { signal, computed } from "@hellajs/core";
import { mount, forEach } from "@hellajs/dom";
import './style.css';
const TodoApp = () => {
const todos = signal([
{ id: 1, text: 'Learn HellaJS', completed: false },
{ id: 2, text: 'Build a todo app', completed: false },
{ id: 3, text: 'Deploy to production', completed: true }
]);
const newTodoText = signal('');
const filter = signal('all'); // 'all', 'active', 'completed'
// Computed values automatically recalculate when dependencies change
const filteredTodos = computed(() => {
const allTodos = todos();
switch (filter()) {
case 'active': return allTodos.filter(t => !t.completed);
case 'completed': return allTodos.filter(t => t.completed);
default: return allTodos;
}
});
const stats = computed(() => {
const allTodos = todos();
return {
total: allTodos.length,
active: allTodos.filter(t => !t.completed).length,
completed: allTodos.filter(t => t.completed).length
};
});
const addTodo = () => {
const text = newTodoText().trim();
if (!text) return;
todos([...todos(), { id: Date.now(), text, completed: false }]);
newTodoText('');
};
const toggleTodo = (id) => {
todos(todos().map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const removeTodo = (id) => {
todos(todos().filter(todo => todo.id !== id));
};
return (
<div class="p-4 max-w-md mx-auto">
<h1 class="text-2xl mb-4 text-center">Todo App</h1>
<div class="flex gap-2 mb-4">
<input
type="text"
value={newTodoText}
onInput={e => newTodoText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
class="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={addTodo}
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Add
</button>
</div>
<div class="flex gap-2 mb-4">
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'all'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('all')}
>
All ({stats().total})
</button>
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'active'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('active')}
>
Active ({stats().active})
</button>
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'completed'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('completed')}
>
Done ({stats().completed})
</button>
</div>
<ul class="space-y-2">
{forEach(filteredTodos, todo => (
<li key={todo.id} class="flex items-center gap-2 p-2 border rounded">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span class={`flex-1 ${todo.completed ? 'line-through text-gray-500' : ''}`}>
{todo.text}
</span>
<button
onClick={() => removeTodo(todo.id)}
class="px-2 py-1 bg-red-500 text-white rounded text-sm"
>
Delete
</button>
</li>
))}
</ul>
{filteredTodos().length === 0 && (
<div class="text-center text-gray-500 mt-4">
{filter() === 'all' && "No todos yet! Add one above."}
{filter() === 'active' && "No active todos. Great job!"}
{filter() === 'completed' && "No completed todos yet."}
</div>
)}
</div>
);
};
mount(TodoApp, '#app');
Code Explanation
computed(() => { ... })
- Creates filtered view that updates whentodos
orfilter
changesstats
computed - Calculates live statistics (total, active, completed counts)- Multiple dependencies - Computeds automatically track both
todos()
andfilter()
signals - Reactive button styling -
class={...}
changes button appearance based on active filter - Dynamic counts -
{stats().total}
shows live counts in button text - Contextual empty states - Different messages based on current filter state
Computed values are automatically cached and only recalculate when their dependencies actually change.
✅ Efficient: const filtered = computed(() => todos().filter(...))
⛔ Inefficient: const filtered = () => todos().filter(...)
(recalculates every time)
Interactive Editing
Let’s add inline editing using advanced reactive state management with multiple coordinated signals.
This feature introduces editing state management and shows how multiple signals work together.
import { signal, computed } from "@hellajs/core";
import { mount, forEach } from "@hellajs/dom";
import './style.css';
const TodoApp = () => {
const todos = signal([
{ id: 1, text: 'Learn HellaJS', completed: false },
{ id: 2, text: 'Build a todo app', completed: false },
{ id: 3, text: 'Deploy to production', completed: true }
]);
const newTodoText = signal('');
const filter = signal('all');
const editingId = signal(null);
const editText = signal('');
const filteredTodos = computed(() => {
const allTodos = todos();
switch (filter()) {
case 'active': return allTodos.filter(t => !t.completed);
case 'completed': return allTodos.filter(t => t.completed);
default: return allTodos;
}
});
const stats = computed(() => {
const allTodos = todos();
return {
total: allTodos.length,
active: allTodos.filter(t => !t.completed).length,
completed: allTodos.filter(t => t.completed).length
};
});
const addTodo = () => {
const text = newTodoText().trim();
if (!text) return;
todos([...todos(), { id: Date.now(), text, completed: false }]);
newTodoText('');
};
const toggleTodo = (id) => {
todos(todos().map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const removeTodo = (id) => {
todos(todos().filter(todo => todo.id !== id));
};
const startEditing = (id, currentText) => {
editingId(id);
editText(currentText);
};
const saveEdit = () => {
const id = editingId();
const newText = editText().trim();
if (!newText) {
removeTodo(id);
} else {
todos(todos().map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
));
}
editingId(null);
editText('');
};
const cancelEdit = () => {
editingId(null);
editText('');
};
const handleEditKeydown = (e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
};
return (
<div class="p-4 max-w-md mx-auto">
<h1 class="text-2xl mb-4 text-center">Todo App</h1>
<div class="flex gap-2 mb-4">
<input
type="text"
value={newTodoText}
onInput={e => newTodoText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
class="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={addTodo}
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Add
</button>
</div>
<div class="flex gap-2 mb-4">
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'all'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('all')}
>
All ({stats().total})
</button>
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'active'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('active')}
>
Active ({stats().active})
</button>
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'completed'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('completed')}
>
Done ({stats().completed})
</button>
</div>
<ul class="space-y-2">
{forEach(filteredTodos, todo => (
<li key={todo.id} class="flex items-center gap-2 p-2 border rounded">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{editingId() === todo.id ? (
<input
type="text"
value={editText}
onInput={e => editText(e.target.value)}
onKeyDown={handleEditKeydown}
onBlur={() => saveEdit()}
class="flex-1 px-2 py-1 border rounded"
autofocus
/>
) : (
<span
class={`flex-1 cursor-pointer ${todo.completed ? 'line-through text-gray-500' : ''}`}
onDblclick={() => startEditing(todo.id, todo.text)}
title="Double-click to edit"
>
{todo.text}
</span>
)}
<div class="flex gap-1">
{editingId() === todo.id ? (
<>
<button
onClick={saveEdit}
class="px-2 py-1 bg-green-500 text-white rounded text-sm"
>
✓
</button>
<button
onClick={cancelEdit}
class="px-2 py-1 bg-gray-500 text-white rounded text-sm"
>
✕
</button>
</>
) : (
<button
onClick={() => removeTodo(todo.id)}
class="px-2 py-1 bg-red-500 text-white rounded text-sm"
>
Delete
</button>
)}
</div>
</li>
))}
</ul>
{filteredTodos().length === 0 && (
<div class="text-center text-gray-500 mt-4">
{filter() === 'all' && "No todos yet! Add one above."}
{filter() === 'active' && "No active todos. Great job!"}
{filter() === 'completed' && "No completed todos yet."}
</div>
)}
</div>
);
};
mount(TodoApp, '#app');
Code Explanation
editingId
signal - Tracks which todo is currently being edited (null when none)editText
signal - Stores the temporary text while editing- Conditional rendering -
{editingId() === todo.id ? (...) : (...)}
switches between edit and display mode - Event coordination -
ondblclick
,onKeyDown
, andonblur
work together for editing - State coordination - Multiple signals (
editingId
,editText
,todos
) work together seamlessly - Smart validation - Empty text triggers todo deletion instead of saving empty content
State Persistence
Now let’s make our app production-ready by adding data persistence and user experience enhancements. This showcases how effects handle side actions elegantly.
We’ll add several effects that make the app feel professional.
import { signal, computed, effect } from "@hellajs/core";
import { mount, forEach } from "@hellajs/dom";
import './style.css';
// Load todos from localStorage
const loadTodos = () => {
try {
const saved = localStorage.getItem('hellajs-todos');
return saved ? JSON.parse(saved) : [
{ id: 1, text: 'Learn HellaJS', completed: false },
{ id: 2, text: 'Build a todo app', completed: false }
];
} catch {
return [];
}
};
const TodoApp = () => {
const todos = signal(loadTodos());
const newTodoText = signal('');
const filter = signal('all');
const editingId = signal(null);
const editText = signal('');
// Persist todos to localStorage
effect(() => {
localStorage.setItem('hellajs-todos', JSON.stringify(todos()));
});
// Update document title
effect(() => {
const activeCount = todos().filter(t => !t.completed).length;
document.title = activeCount === 0
? 'Todo App'
: `Todo App (${activeCount} active)`;
});
const filteredTodos = computed(() => {
const allTodos = todos();
switch (filter()) {
case 'active': return allTodos.filter(t => !t.completed);
case 'completed': return allTodos.filter(t => t.completed);
default: return allTodos;
}
});
const stats = computed(() => {
const allTodos = todos();
return {
total: allTodos.length,
active: allTodos.filter(t => !t.completed).length,
completed: allTodos.filter(t => t.completed).length
};
});
const addTodo = () => {
const text = newTodoText().trim();
if (!text) return;
todos([...todos(), { id: Date.now(), text, completed: false }]);
newTodoText('');
};
const toggleTodo = (id) => {
todos(todos().map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
};
const removeTodo = (id) => {
todos(todos().filter(todo => todo.id !== id));
};
const clearCompleted = () => {
todos(todos().filter(todo => !todo.completed));
};
const startEditing = (id, currentText) => {
editingId(id);
editText(currentText);
};
const saveEdit = () => {
const id = editingId();
const newText = editText().trim();
if (!newText) {
removeTodo(id);
} else {
todos(todos().map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
));
}
editingId(null);
editText('');
};
const cancelEdit = () => {
editingId(null);
editText('');
};
const handleEditKeydown = (e) => {
if (e.key === 'Enter') saveEdit();
if (e.key === 'Escape') cancelEdit();
};
return (
<div class="p-4 max-w-md mx-auto">
<div class="flex justify-between items-center mb-4">
<h1 class="text-2xl">Todo App</h1>
<div class="text-sm text-gray-500">
{stats().active} active, {stats().completed} done
</div>
</div>
<div class="flex gap-2 mb-4">
<input
type="text"
value={newTodoText}
onInput={e => newTodoText(e.target.value)}
onKeyDown={e => e.key === 'Enter' && addTodo()}
placeholder="What needs to be done?"
class="flex-1 px-3 py-2 border rounded"
/>
<button
onClick={addTodo}
class="px-4 py-2 bg-blue-500 text-white rounded"
>
Add
</button>
</div>
<div class="flex gap-2 mb-4">
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'all'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('all')}
>
All ({stats().total})
</button>
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'active'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('active')}
>
Active ({stats().active})
</button>
<button
class={`px-3 py-1 rounded text-sm ${
filter() === 'completed'
? 'bg-blue-500 text-white'
: 'bg-gray-200 text-gray-700'
}`}
onClick={() => filter('completed')}
>
Done ({stats().completed})
</button>
{stats().completed > 0 && (
<button
onClick={clearCompleted}
class="px-3 py-1 rounded text-sm bg-red-100 text-red-700 hover:bg-red-200"
>
Clear Done
</button>
)}
</div>
<ul class="space-y-2">
{forEach(filteredTodos, todo => (
<li key={todo.id} class="flex items-center gap-2 p-2 border rounded">
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
{editingId() === todo.id ? (
<input
type="text"
value={editText}
onInput={e => editText(e.target.value)}
onKeyDown={handleEditKeydown}
onBlur={() => saveEdit()}
class="flex-1 px-2 py-1 border rounded"
autofocus
/>
) : (
<span
class={`flex-1 cursor-pointer ${todo.completed ? 'line-through text-gray-500' : ''}`}
onDblclick={() => startEditing(todo.id, todo.text)}
title="Double-click to edit"
>
{todo.text}
</span>
)}
<div class="flex gap-1">
{editingId() === todo.id ? (
<>
<button
onClick={saveEdit}
class="px-2 py-1 bg-green-500 text-white rounded text-sm"
>
✓
</button>
<button
onClick={cancelEdit}
class="px-2 py-1 bg-gray-500 text-white rounded text-sm"
>
✕
</button>
</>
) : (
<button
onClick={() => removeTodo(todo.id)}
class="px-2 py-1 bg-red-500 text-white rounded text-sm"
>
Delete
</button>
)}
</div>
</li>
))}
</ul>
{filteredTodos().length === 0 && (
<div class="text-center text-gray-500 mt-4">
{filter() === 'all' && "No todos yet! Add one above."}
{filter() === 'active' && "No active todos. Great job!"}
{filter() === 'completed' && "No completed todos yet."}
</div>
)}
</div>
);
};
mount(TodoApp, '#app');
Code Explanation
loadTodos()
function - Loads persisted todos from localStorage with error handling- Persistence effect -
effect(() => { localStorage.setItem(...) })
saves todos automatically - Title effect -
effect(() => { document.title = ... })
updates browser tab title - Progressive disclosure -
{stats().completed > 0 && (...)}
shows “Clear Done” conditionally - Error handling - Try/catch blocks prevent localStorage errors from breaking the app
- Enhanced UX - Live statistics in header, bulk operations, and visual feedback
Next Steps
Ready to level up even further? Here are your best next steps.
- State Management - Learn advanced state patterns and organization
- Components Guide - Build reusable, composable components
- Styling Guide - Add beautiful, dynamic CSS to your applications
The patterns you learned here - complex state management, computed derivations, effects, and clean architecture - are the foundation for any modern reactive application. You’re ready to build amazing apps with HellaJS!