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 data
  • const newTodoText = signal('') - Creates reactive state for the new todo input field
  • {newTodoText} - Creates reactive two-way binding for the input field
  • forEach(todos, todo => (...)) - Renders the todo list with optimized list diffing
  • Array immutability - Using spread operator [...todos(), newTodo] for reactive updates
  • Event handlers - onClick, onInput, and onKeyDown 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

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 when todos or filter changes
  • stats computed - Calculates live statistics (total, active, completed counts)
  • Multiple dependencies - Computeds automatically track both todos() and filter() 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, and onblur 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.

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!