Stimulus Project — Build a Complete Application
In this tutorial, you'll learn how to build a complete Stimulus Project. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.
This complete Stimulus project builds a task manager application with controllers, targets, values, classes, outlets, Turbo integration, and TypeScript.
What You'll Build
A fully functional task manager with:
- Task creation, editing, and deletion
- Real-time search with debounce
- Category filtering and status filtering
- Pagination with keyboard shortcuts
- Modal-based task editing
- Turbo Drive navigation
- TypeScript for type safety
Why It Matters
Working through a complete project from start to finish bridges the gap between learning individual concepts and building real applications. Each controller solves a specific problem, and together they form a cohesive, maintainable system. The Doda Browser extension uses similar patterns for its settings panel, bookmark manager, and tab organizer.
Learning Path
flowchart LR A[Testing] --> B[Project] B --> C[Next Steps:
Advanced Patterns] B --> D[Real Projects:
DodaTech Tools] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style D fill:#059669,color:#fff
Project Overview
task-manager/
├── index.html
├── tsconfig.json
├── package.json
├── src/
│ ├── index.ts # Application entry point
│ ├── controllers/
│ │ ├── task_form.ts # Task creation/editing
│ │ ├── task_list.ts # Task rendering
│ │ ├── search.ts # Search with debounce
│ │ ├── filters.ts # Category/status filters
│ │ ├── pagination.ts # Page navigation
│ │ ├── modal.ts # Modal overlay
│ │ └── keyboard.ts # Global keyboard shortcuts
│ └── styles/
│ └── main.css
Setup
package.json
{
"name": "stimulus-task-manager",
"version": "1.0.0",
"scripts": {
"dev": "esbuild src/index.ts --bundle --outfile=dist/app.js --servedir=. --watch",
"build": "esbuild src/index.ts --bundle --outfile=dist/app.js"
},
"dependencies": {
"@hotwired/stimulus": "^3.2.0",
"@hotwired/turbo": "^8.0.0"
},
"devDependencies": {
"esbuild": "^0.20.0",
"typescript": "^5.4.0"
}
}
index.html
<!DOCTYPE html>
<html lang="en" data-controller="keyboard">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Task Manager</title>
<link rel="stylesheet" href="dist/styles.css">
<script type="module" src="dist/app.js"></script>
</head>
<body>
<div class="container" data-controller="task-form task-list search filters pagination"
data-action="keydown@document->keyboard#handleKey">
<header>
<h1>Task Manager</h1>
<p>Press <kbd>?</kbd> for shortcuts</p>
</header>
<!-- Search -->
<div class="search-bar" data-controller="search"
data-search-target="container"
data-action="input->search#search">
<input type="text" data-search-target="input"
placeholder="Search tasks..." class="search-input">
<button data-action="click->search#clear" class="btn-secondary">Clear</button>
</div>
<!-- Filters -->
<div class="filters" data-controller="filters"
data-filters-target="container">
<select data-filters-target="category"
data-action="change->filters#apply">
<option value="all">All Categories</option>
<option value="work">Work</option>
<option value="personal">Personal</option>
<option value="urgent">Urgent</option>
</select>
<select data-filters-target="status"
data-action="change->filters#apply">
<option value="all">All Status</option>
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<!-- Add Task Button -->
<button data-action="click->task-form#openNew"
class="btn-primary">
+ Add Task
</button>
<!-- Task List -->
<div data-controller="task-list"
data-task-list-target="container"
data-search-outlet=".search"
data-filters-outlet=".filters"
data-pagination-outlet=".pagination">
<div data-task-list-target="items" class="task-list">
<!-- Tasks rendered here -->
</div>
<p data-task-list-target="empty" class="hidden">No tasks found</p>
</div>
<!-- Pagination -->
<div data-controller="pagination"
class="pagination"
data-pagination-target="container"
data-task-list-outlet=".task-list">
<!-- Pagination buttons rendered here -->
</div>
</div>
<!-- Modal -->
<div data-controller="modal"
data-modal-open-class="modal--open"
data-modal-close-class="modal--close">
<div data-modal-target="overlay"
data-action="click->modal#close"></div>
<div data-modal-target="content" class="modal-content">
<form data-controller="task-form"
data-task-form-target="form"
data-action="submit->task-form#submit">
<h2 data-task-form-target="title">New Task</h2>
<div class="form-group">
<label>Title</label>
<input type="text" data-task-form-target="titleInput" required>
</div>
<div class="form-group">
<label>Category</label>
<select data-task-form-target="categorySelect">
<option value="work">Work</option>
<option value="personal">Personal</option>
<option value="urgent">Urgent</option>
</select>
</div>
<div class="form-group">
<label>Status</label>
<select data-task-form-target="statusSelect">
<option value="todo">To Do</option>
<option value="in-progress">In Progress</option>
<option value="done">Done</option>
</select>
</div>
<div class="form-actions">
<button type="submit" class="btn-primary">Save</button>
<button type="button" data-action="click->modal#close"
class="btn-secondary">Cancel</button>
</div>
</form>
</div>
</div>
</body>
</html>
Application Entry Point
import { Application } from '@hotwired/stimulus';
import * as Turbo from '@hotwired/turbo';
import TaskFormController from './controllers/task_form';
import TaskListController from './controllers/task_list';
import SearchController from './controllers/search';
import FiltersController from './controllers/filters';
import PaginationController from './controllers/pagination';
import ModalController from './controllers/modal';
import KeyboardController from './controllers/keyboard';
const app = Application.start();
app.debug = process.env.NODE_ENV === 'development';
app.register('task-form', TaskFormController);
app.register('task-list', TaskListController);
app.register('search', SearchController);
app.register('filters', FiltersController);
app.register('pagination', PaginationController);
app.register('modal', ModalController);
app.register('keyboard', KeyboardController);
Controllers
task_form.ts
import { Controller } from '@hotwired/stimulus';
interface Task {
id: string;
title: string;
category: string;
status: string;
createdAt: string;
}
export default class extends Controller {
static targets = ['form', 'title', 'titleInput', 'categorySelect', 'statusSelect'];
static values = { editingId: String };
declare readonly formTarget: HTMLFormElement;
declare readonly titleTarget: HTMLElement;
declare readonly titleInputTarget: HTMLInputElement;
declare readonly categorySelectTarget: HTMLSelectElement;
declare readonly statusSelectTarget: HTMLSelectElement;
declare readonly editingIdValue: string;
declare readonly hasEditingIdValue: boolean;
#tasks: Task[] = [];
connect(): void {
this.loadTasks();
}
openNew(): void {
this.titleTarget.textContent = 'New Task';
this.formTarget.reset();
this.editingIdValue = '';
this.dispatch('open-modal');
}
edit(task: Task): void {
this.titleTarget.textContent = 'Edit Task';
this.titleInputTarget.value = task.title;
this.categorySelectTarget.value = task.category;
this.statusSelectTarget.value = task.status;
this.editingIdValue = task.id;
this.dispatch('open-modal');
}
submit(event: SubmitEvent): void {
event.preventDefault();
const taskData = {
title: this.titleInputTarget.value.trim(),
category: this.categorySelectTarget.value,
status: this.statusSelectTarget.value
};
if (this.hasEditingIdValue) {
this.updateTask(this.editingIdValue, taskData);
} else {
this.createTask(taskData);
}
this.dispatch('close-modal');
this.dispatch('tasks-changed');
}
private createTask(data: Partial<Task>): void {
const task: Task = {
id: crypto.randomUUID(),
title: data.title!,
category: data.category!,
status: data.status!,
createdAt: new Date().toISOString()
};
this.#tasks.push(task);
this.saveTasks();
}
private updateTask(id: string, data: Partial<Task>): void {
const index = this.#tasks.findIndex(t => t.id === id);
if (index !== -1) {
this.#tasks[index] = { ...this.#tasks[index], ...data };
this.saveTasks();
}
}
deleteTask(id: string): void {
this.#tasks = this.#tasks.filter(t => t.id !== id);
this.saveTasks();
this.dispatch('tasks-changed');
}
getTasks(): Task[] {
return [...this.#tasks];
}
private loadTasks(): void {
try {
const data = localStorage.getItem('tasks');
if (data) this.#tasks = JSON.parse(data);
} catch {
this.#tasks = [];
}
}
private saveTasks(): void {
localStorage.setItem('tasks', JSON.stringify(this.#tasks));
}
}
task_list.ts
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['items', 'empty'];
static outlets = ['search', 'filters', 'pagination'];
declare readonly itemsTarget: HTMLElement;
declare readonly emptyTarget: HTMLElement;
declare readonly hasSearchOutlet: boolean;
declare readonly hasFiltersOutlet: boolean;
declare readonly hasPaginationOutlet: boolean;
#taskFormController: any = null;
#allTasks: any[] = [];
#currentPage = 1;
#perPage = 5;
connect(): void {
this.#taskFormController = this.#findTaskFormController();
this.render();
}
tasksChanged(): void {
this.#allTasks = this.#taskFormController?.getTasks() || [];
this.#currentPage = 1;
this.render();
}
render(): void {
const filtered = this.#applyFilters();
const paginated = this.#paginate(filtered);
if (paginated.length === 0) {
this.itemsTarget.innerHTML = '';
this.emptyTarget.classList.remove('hidden');
return;
}
this.emptyTarget.classList.add('hidden');
this.itemsTarget.innerHTML = paginated.map(task => `
<div class="task-item status-${task.status}" data-task-id="${task.id}">
<div class="task-info">
<h3>${this.#escapeHtml(task.title)}</h3>
<span class="badge category-${task.category}">${task.category}</span>
<span class="badge status-${task.status}">${task.status}</span>
</div>
<div class="task-actions">
<button data-action="click->task-form#edit"
data-task='${this.#escapeAttr(JSON.stringify(task))}'
class="btn-small">Edit</button>
<button data-action="click->task-form#deleteTask"
data-id="${task.id}"
class="btn-small btn-danger">Delete</button>
</div>
</div>
`).join('');
this.paginationOutlet?.render(filtered.length, this.#currentPage, this.#perPage);
}
goToPage(page: number): void {
this.#currentPage = page;
this.render();
}
get currentPage(): number {
return this.#currentPage;
}
get perPage(): number {
return this.#perPage;
}
#applyFilters(): any[] {
let tasks = this.#allTasks;
if (this.hasSearchOutlet) {
const query = this.searchOutlet.queryValue?.toLowerCase();
if (query) {
tasks = tasks.filter(t => t.title.toLowerCase().includes(query));
}
}
if (this.hasFiltersOutlet) {
const category = this.filtersOutlet.categoryValue;
const status = this.filtersOutlet.statusValue;
if (category && category !== 'all') {
tasks = tasks.filter(t => t.category === category);
}
if (status && status !== 'all') {
tasks = tasks.filter(t => t.status === status);
}
}
return tasks;
}
#paginate(tasks: any[]): any[] {
const start = (this.#currentPage - 1) * this.#perPage;
return tasks.slice(start, start + this.#perPage);
}
#findTaskFormController(): any {
const element = document.querySelector('[data-controller~="task-form"]');
if (element) {
const app = (window as any).Stimulus?.Application;
if (app) {
const id = 'task-form';
return app.getControllerForElementAndIdentifier(element, id);
}
}
return null;
}
#escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
#escapeAttr(json: string): string {
return json.replace(/"/g, '"');
}
}
search.ts
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['input'];
static values = { query: { type: String, default: '' }, delay: { type: Number, default: 300 } };
declare readonly inputTarget: HTMLInputElement;
declare readonly queryValue: string;
declare readonly delayValue: number;
#timer: ReturnType<typeof setTimeout> | null = null;
search(): void {
this.#timer = setTimeout(() => {
this.queryValue = this.inputTarget.value;
this.dispatch('search-changed');
}, this.delayValue);
}
clear(): void {
this.inputTarget.value = '';
this.queryValue = '';
this.dispatch('search-changed');
}
disconnect(): void {
if (this.#timer) clearTimeout(this.#timer);
}
}
filters.ts
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['category', 'status'];
static values = {
category: { type: String, default: 'all' },
status: { type: String, default: 'all' }
};
declare readonly categoryTarget: HTMLSelectElement;
declare readonly statusTarget: HTMLSelectElement;
apply(): void {
this.categoryValue = this.categoryTarget.value;
this.statusValue = this.statusTarget.value;
this.dispatch('filter-changed');
}
reset(): void {
this.categoryTarget.value = 'all';
this.statusTarget.value = 'all';
this.apply();
}
}
pagination.ts
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['container'];
static outlets = ['taskList'];
declare readonly containerTarget: HTMLElement;
declare readonly hasTaskListOutlet: boolean;
render(total: number, currentPage: number, perPage: number): void {
const totalPages = Math.ceil(total / perPage);
if (totalPages <= 1) {
this.containerTarget.innerHTML = '';
return;
}
const pages = this.#getPageRange(currentPage, totalPages);
this.containerTarget.innerHTML = `
<button data-action="click->pagination#goToPage"
data-page="${currentPage - 1}"
${currentPage <= 1 ? 'disabled' : ''}
class="page-btn">Previous</button>
${pages.map(p => `
<button data-action="click->pagination#goToPage"
data-page="${p}"
class="page-btn ${p === currentPage ? 'active' : ''}">${p}</button>
`).join('')}
<button data-action="click->pagination#goToPage"
data-page="${currentPage + 1}"
${currentPage >= totalPages ? 'disabled' : ''}
class="page-btn">Next</button>
`;
}
goToPage(event: MouseEvent): void {
const page = parseInt((event.currentTarget as HTMLElement).dataset.page || '1');
this.taskListOutlet?.goToPage(page);
}
#getPageRange(current: number, total: number): number[] {
const delta = 2;
const start = Math.max(1, current - delta);
const end = Math.min(total, current + delta);
return Array.from({ length: end - start + 1 }, (_, i) => start + i);
}
}
modal.ts
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['overlay', 'content'];
static classes = ['open', 'close'];
declare readonly overlayTarget: HTMLElement;
declare readonly contentTarget: HTMLElement;
declare readonly openClass: string;
declare readonly closeClass: string;
declare readonly hasOpenClass: boolean;
declare readonly hasCloseClass: boolean;
connect(): void {
this.element.classList.add(this.closeClass || 'modal--base');
document.addEventListener('task-form:open-modal', () => this.open());
document.addEventListener('task-form:close-modal', () => this.close());
}
open(): void {
if (this.hasCloseClass) this.element.classList.remove(this.closeClass);
if (this.hasOpenClass) this.element.classList.add(this.openClass);
document.body.classList.add('modal-open');
}
close(): void {
if (this.hasOpenClass) this.element.classList.remove(this.openClass);
if (this.hasCloseClass) this.element.classList.add(this.closeClass);
document.body.classList.remove('modal-open');
}
}
keyboard.ts
import { Controller } from '@hotwired/stimulus';
export default class extends Controller {
connect(): void {
this.element.setAttribute('tabindex', '0');
}
handleKey(event: KeyboardEvent): void {
if (event.key === '?') {
event.preventDefault();
this.showHelp();
} else if (event.key === 'Escape') {
this.closeModals();
} else if (event.ctrlKey && event.key === 'n') {
event.preventDefault();
this.newTask();
}
}
showHelp(): void {
alert([
'Keyboard Shortcuts:',
' ? - Show this help',
' Esc - Close modals',
' Ctrl+N - New task',
' / or Ctrl+F - Focus search',
' Ctrl+S - Save current form'
].join('\n'));
}
closeModals(): void {
document.querySelectorAll('[data-controller~="modal"]').forEach(el => {
const app = (window as any).Stimulus?.Application;
if (app) {
const modal = app.getControllerForElementAndIdentifier(el, 'modal');
modal?.close();
}
});
}
newTask(): void {
const formEl = document.querySelector('[data-controller~="task-form"]');
const app = (window as any).Stimulus?.Application;
if (app && formEl) {
const form = app.getControllerForElementAndIdentifier(formEl, 'task-form');
form?.openNew();
}
}
}
Running the Project
# Install dependencies
npm install
# Start development server
npm run dev
# Build for production
npm run build
Open http://localhost:8000 in your browser. The task manager should work fully with search, filters, pagination, modals, and keyboard shortcuts.
What You've Learned
This project demonstrates all major Stimulus concepts working together:
- Controllers: 7 independent controllers for separation of concerns
- Targets: DOM element references for form inputs, lists, modals
- Actions: Event bindings for clicks, submits, keyboard events
- Values: Configuration for search query, filters, pagination state
- Classes: Modal open/close CSS class mapping
- Outlets: Cross-controller communication between search, filters, pagination, and task list
- Lifecycle: connect/disconnect for initialization and cleanup
- Turbo: Ready for Turbo Drive navigation
- TypeScript: Type safety for all controllers and data structures
Common Mistakes
1. Tight Controller Coupling
Use events or outlets instead of directly importing one controller into another.
2. Not Handling Missing Outlets
Always check this.hasNameOutlet before accessing this.nameOutlet.
3. Forgetting to Clean Up in disconnect
Clear timers, remove event listeners, and cancel fetch requests.
4. Storing State in the DOM
Use values or private fields instead of reading state from DOM attributes.
5. Not Testing Controller Interactions
Write integration tests that verify how controllers work together.
Practice Questions
1. How does the task list get updated when a new task is created?
The task-form dispatches tasks-changed, which the task-list listens for via a Stimulus action or event listener, causing it to re-render.
2. How do search and filters interact with pagination?
Search and filters reduce the total task count, which updates the pagination. Pagination recalculates total pages and re-renders page buttons.
3. How does the modal controller know when to open?
It listens for task-form:open-modal and task-form:close-modal custom events dispatched by the task-form controller.
4. What happens when a user presses Ctrl+N?
The keyboard controller triggers the task-form's openNew() method, which opens the modal with an empty form.
Challenge
Add a new sort controller that allows sorting tasks by title, date, or status. Connect it via outlets to the task-list controller. Add keyboard shortcuts for cycling through sort options.
FAQ
What's Next
| Topic | Description |
|---|---|
| Hotwire Turbo | Learn Turbo Drive, Frames, and Streams for page navigation |
| Stimulus Reference | Complete Stimulus API reference and advanced patterns |
| JavaScript Projects | More project-based tutorials for practical experience |
Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This project architecture powers the settings panels, bookmark managers, and tab organizers in the Doda Browser extension.
What's Next
Congratulations on completing this Stimulus Project tutorial! Here's where to go from here:
- Practice daily — Consistency is more important than long study sessions
- Build a project — Apply what you learned by building something real
- Explore related topics — Check out other tutorials in the same category
- Join the community — Discuss with other learners and share your progress
Remember: every expert was once a beginner. Keep coding!
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro