Intermediate Tutorial
Take your Aurelia skills to the next level by building a feature-rich todo application. This tutorial covers component composition, filtering, local storage, and real-world patterns.
What You'll Learn
Creating multiple components and composing them
Component communication with bindable properties
Advanced list rendering and filtering
Form handling with validation
Local storage persistence
Computed properties and reactive updates
Template patterns for real apps
Prerequisites
Completed the Hello World Tutorial
Basic understanding of Templates
Familiarity with TypeScript
The App We're Building
A todo application with:
✅ Add, complete, and delete tasks
🏷️ Categorize tasks (Work, Personal, Shopping)
🔍 Filter by category and completion status
💾 Auto-save to local storage
📊 Task statistics
Step 1: Project Setup
npx makes aurelia
# Name: todo-app
# Select TypeScript
cd todo-app
npm run devStep 2: Data Models
Create src/models.ts:
export interface Todo {
id: string;
title: string;
description: string;
category: Category;
completed: boolean;
createdAt: Date;
}
export type Category = 'work' | 'personal' | 'shopping';
export const CATEGORIES: Category[] = ['work', 'personal', 'shopping'];
export const CATEGORY_LABELS: Record<Category, string> = {
work: 'Work',
personal: 'Personal',
shopping: 'Shopping'
};
export const CATEGORY_COLORS: Record<Category, string> = {
work: '#3b82f6',
personal: '#10b981',
shopping: '#f59e0b'
};Step 3: Storage Service
Create src/storage-service.ts:
import { DI } from 'aurelia';
export const IStorageService = DI.createInterface<IStorageService>(
'IStorageService',
x => x.singleton(StorageService)
);
export interface IStorageService extends StorageService {}
export class StorageService {
private readonly STORAGE_KEY = 'aurelia-todos';
saveTodos(todos: any[]): void {
try {
localStorage.setItem(this.STORAGE_KEY, JSON.stringify(todos));
} catch (error) {
console.error('Failed to save todos:', error);
}
}
loadTodos(): any[] {
try {
const data = localStorage.getItem(this.STORAGE_KEY);
return data ? JSON.parse(data) : [];
} catch (error) {
console.error('Failed to load todos:', error);
return [];
}
}
clearTodos(): void {
localStorage.removeItem(this.STORAGE_KEY);
}
}The service is automatically registered as a singleton via DI.createInterface.
Step 4: Main App Component
Update src/my-app.ts:
import { resolve } from 'aurelia';
import { IStorageService } from './storage-service';
import { Todo, Category, CATEGORIES } from './models';
export class MyApp {
private readonly storage = resolve(IStorageService);
todos: Todo[] = [];
filterCategory: Category | 'all' = 'all';
filterCompleted: 'all' | 'active' | 'completed' = 'all';
constructor() {
this.loadTodos();
}
// Computed property for filtered todos
get filteredTodos(): Todo[] {
let filtered = this.todos;
// Filter by category
if (this.filterCategory !== 'all') {
filtered = filtered.filter(todo => todo.category === this.filterCategory);
}
// Filter by completion status
if (this.filterCompleted === 'active') {
filtered = filtered.filter(todo => !todo.completed);
} else if (this.filterCompleted === 'completed') {
filtered = filtered.filter(todo => todo.completed);
}
return filtered;
}
// Statistics computed properties
get totalTodos(): number {
return this.todos.length;
}
get activeTodos(): number {
return this.todos.filter(todo => !todo.completed).length;
}
get completedTodos(): number {
return this.todos.filter(todo => todo.completed).length;
}
get categories(): (Category | 'all')[] {
return ['all', ...CATEGORIES];
}
// Todo operations
addTodo(todo: Omit<Todo, 'id' | 'createdAt'>): void {
const newTodo: Todo = {
...todo,
id: crypto.randomUUID(),
createdAt: new Date()
};
this.todos.push(newTodo);
this.saveTodos();
}
toggleTodo(todo: Todo): void {
todo.completed = !todo.completed;
this.saveTodos();
}
deleteTodo(todo: Todo): void {
const index = this.todos.indexOf(todo);
if (index > -1) {
this.todos.splice(index, 1);
this.saveTodos();
}
}
clearCompleted(): void {
this.todos = this.todos.filter(todo => !todo.completed);
this.saveTodos();
}
// Persistence
private saveTodos(): void {
this.storage.saveTodos(this.todos);
}
private loadTodos(): void {
const loaded = this.storage.loadTodos();
this.todos = loaded.map(todo => ({
...todo,
createdAt: new Date(todo.createdAt)
}));
}
}Step 5: Create Todo Form Component
Create src/todo-form.ts:
import { bindable } from 'aurelia';
import { Category, CATEGORIES, CATEGORY_LABELS } from './models';
export class TodoForm {
@bindable onSubmit?: (data: any) => void;
formData = {
title: '',
description: '',
category: 'work' as Category
};
categories = CATEGORIES;
categoryLabels = CATEGORY_LABELS;
get isValid(): boolean {
return this.formData.title.trim().length > 0;
}
handleSubmit(): void {
if (!this.isValid) return;
this.onSubmit?.({
title: this.formData.title.trim(),
description: this.formData.description.trim(),
category: this.formData.category,
completed: false
});
this.resetForm();
}
resetForm(): void {
this.formData = {
title: '',
description: '',
category: 'work'
};
}
}Create src/todo-form.html:
<div class="todo-form">
<h2>Add New Todo</h2>
<form submit.trigger="handleSubmit()">
<div class="form-group">
<label for="title">Title *</label>
<input
id="title"
type="text"
value.bind="formData.title"
placeholder="Enter todo title"
required />
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
value.bind="formData.description"
placeholder="Optional description"
rows="3"></textarea>
</div>
<div class="form-group">
<label for="category">Category</label>
<select id="category" value.bind="formData.category">
<option repeat.for="cat of categories" value.bind="cat">
${categoryLabels[cat]}
</option>
</select>
</div>
<button type="submit" disabled.bind="!isValid">
Add Todo
</button>
</form>
</div>Step 6: Create Todo Item Component
Create src/todo-item.ts:
import { bindable } from 'aurelia';
import { Todo, CATEGORY_LABELS, CATEGORY_COLORS } from './models';
export class TodoItem {
@bindable todo!: Todo;
@bindable onToggle?: (todo: Todo) => void;
@bindable onDelete?: (todo: Todo) => void;
categoryLabels = CATEGORY_LABELS;
categoryColors = CATEGORY_COLORS;
get categoryColor(): string {
return this.categoryColors[this.todo.category];
}
handleToggle(): void {
this.onToggle?.(this.todo);
}
handleDelete(): void {
if (confirm(`Delete "${this.todo.title}"?`)) {
this.onDelete?.(this.todo);
}
}
get formattedDate(): string {
return this.todo.createdAt.toLocaleDateString();
}
}Create src/todo-item.html:
<div class="todo-item ${todo.completed ? 'completed' : ''}">
<div class="todo-content">
<label class="todo-checkbox">
<input
type="checkbox"
checked.bind="todo.completed"
change.trigger="handleToggle()" />
<span class="checkmark"></span>
</label>
<div class="todo-details">
<h3 class="todo-title">${todo.title}</h3>
<p if.bind="todo.description" class="todo-description">
${todo.description}
</p>
<div class="todo-meta">
<span class="todo-category" style="background-color: ${categoryColor}">
${categoryLabels[todo.category]}
</span>
<span class="todo-date">${formattedDate}</span>
</div>
</div>
</div>
<button
class="delete-btn"
click.trigger="handleDelete()"
title="Delete todo">
×
</button>
</div>Step 7: Main App Template
Update src/my-app.html:
<import from="./todo-form"></import>
<import from="./todo-item"></import>
<div class="app">
<header class="app-header">
<h1>📝 Aurelia Todo App</h1>
<div class="stats">
<span class="stat">Total: ${totalTodos}</span>
<span class="stat">Active: ${activeTodos}</span>
<span class="stat">Completed: ${completedTodos}</span>
</div>
</header>
<main class="app-main">
<div class="sidebar">
<todo-form on-submit.call="addTodo($event)"></todo-form>
</div>
<div class="content">
<!-- Filters -->
<div class="filters">
<div class="filter-group">
<label>Category:</label>
<select value.bind="filterCategory">
<option value="all">All Categories</option>
<option repeat.for="cat of categories" value.bind="cat">
${cat === 'all' ? 'All' : cat}
</option>
</select>
</div>
<div class="filter-group">
<label>Status:</label>
<select value.bind="filterCompleted">
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
</div>
<button
if.bind="completedTodos > 0"
click.trigger="clearCompleted()"
class="clear-btn">
Clear Completed
</button>
</div>
<!-- Todo List -->
<div class="todo-list">
<div if.bind="filteredTodos.length === 0" class="empty-state">
<p>No todos found!</p>
<small if.bind="filterCategory !== 'all' || filterCompleted !== 'all'">
Try changing your filters
</small>
</div>
<todo-item
repeat.for="todo of filteredTodos; key: id"
todo.bind="todo"
on-toggle.call="toggleTodo($event)"
on-delete.call="deleteTodo($event)">
</todo-item>
</div>
</div>
</main>
</div>Step 8: Styling
Update src/my-app.css:
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #f5f5f5;
color: #333;
}
.app {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.app-header {
background: white;
padding: 2rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.app-header h1 {
margin-bottom: 1rem;
}
.stats {
display: flex;
gap: 2rem;
}
.stat {
font-size: 0.9rem;
color: #666;
}
.app-main {
display: grid;
grid-template-columns: 350px 1fr;
gap: 2rem;
}
/* Todo Form */
.todo-form {
background: white;
padding: 1.5rem;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.todo-form h2 {
font-size: 1.2rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
}
button {
padding: 0.75rem 1.5rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
button:hover:not(:disabled) {
background: #2563eb;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Filters */
.filters {
background: white;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 1rem;
display: flex;
gap: 1rem;
align-items: flex-end;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.filter-group {
flex: 1;
}
.filter-group label {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9rem;
font-weight: 500;
}
.filter-group select {
width: 100%;
padding: 0.5rem;
border: 1px solid #ddd;
border-radius: 4px;
}
.clear-btn {
background: #ef4444;
}
.clear-btn:hover {
background: #dc2626;
}
/* Todo List */
.todo-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.empty-state {
background: white;
padding: 3rem;
border-radius: 8px;
text-align: center;
color: #999;
}
/* Todo Item */
.todo-item {
background: white;
padding: 1rem;
border-radius: 8px;
display: flex;
justify-content: space-between;
align-items: flex-start;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.todo-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15);
}
.todo-item.completed {
opacity: 0.6;
}
.todo-content {
display: flex;
gap: 1rem;
flex: 1;
}
.todo-checkbox {
cursor: pointer;
position: relative;
}
.todo-checkbox input {
cursor: pointer;
}
.todo-details {
flex: 1;
}
.todo-title {
font-size: 1.1rem;
margin-bottom: 0.25rem;
}
.todo-item.completed .todo-title {
text-decoration: line-through;
}
.todo-description {
color: #666;
font-size: 0.9rem;
margin-bottom: 0.5rem;
}
.todo-meta {
display: flex;
gap: 1rem;
align-items: center;
}
.todo-category {
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
border-radius: 4px;
color: white;
font-weight: 500;
}
.todo-date {
font-size: 0.8rem;
color: #999;
}
.delete-btn {
background: transparent;
color: #ef4444;
border: 1px solid #ef4444;
width: 32px;
height: 32px;
padding: 0;
font-size: 1.5rem;
line-height: 1;
}
.delete-btn:hover {
background: #ef4444;
color: white;
}
@media (max-width: 768px) {
.app-main {
grid-template-columns: 1fr;
}
.filters {
flex-direction: column;
align-items: stretch;
}
}What You've Learned
Component Composition - Created reusable TodoForm and TodoItem components
Component Communication - Used
@bindableand.callfor parent-child communicationDependency Injection - Created and injected StorageService
Computed Properties - Implemented filtered lists and statistics
List Rendering - Used
repeat.forwith keys for efficient updatesConditional Rendering - Showed/hid elements based on state
Form Handling - Built forms with validation and submission
Local Storage - Persisted data across sessions
Template Patterns - Applied real-world templating techniques
Next Steps
Enhance your app with:
Drag-and-drop reordering
Edit mode for todos
Due dates and reminders
Search functionality
Dark mode toggle
Export/import todos
Related Documentation
Last updated
Was this helpful?