Skip to content

Stimulus Project — Build a Complete Application

DodaTech Updated 2026-06-28 11 min read

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, '&quot;');
  }
}

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

### Why use localStorage instead of a backend?

localStorage keeps the project self-contained and deployable as a static site. The controller architecture is the same whether data comes from localStorage, a REST API, or Graphql.

How would I adapt this for real API calls?

Replace localStorage calls in task_form.ts with fetch() calls. Keep the controller structure the same — only the data persistence layer changes.

Can I use this project as a template for real applications?

Yes. Extract the controller patterns and adapt the HTML/CSS for your needs. The outlet-based communication between search, filters, pagination, and list is a proven pattern for real applications.

How do I add user authentication?

Wrap the application in a parent controller that checks auth state. Alternatively, use a Turbo-enabled backend (Rails, Laravel, Django) and let Stimulus enhance the server-rendered HTML.

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