Skip to content

Stimulus Lazy Loading — Turbo Integration & Dynamic Content Guide

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you'll learn about Stimulus Lazy Loading. We cover key concepts, practical examples, and best practices to help you understand and apply this topic effectively.

Stimulus lazy loading techniques handle controllers for dynamically added content, integrate with Turbo page navigations, and defer registration until needed.

What You'll Learn

  • Lazy controller registration with dynamic imports
  • Handling controllers on Turbo-rendered pages
  • Using stimulus-use helpers for common patterns
  • Managing controllers on dynamically inserted HTML
  • Preventing memory leaks with proper disconnect handling
  • Practical patterns for infinite scroll, modals, and Turbo frames

Why It Matters

Modern web applications add content dynamically: infinite scroll loads more items, modals appear on demand, and Turbo Drive navigates between pages without full reloads. Stimulus controllers need to handle these scenarios gracefully. In the Doda Browser extension, lazy loading powers the extension popup which must initialize controllers as panels load dynamically.

Learning Path

flowchart LR
  A[Outlets] --> B[Lazy
Loading] B --> C[TypeScript] C --> D[Testing] B --> E[Real Projects:
DodaTech Tools] style B fill:#4f46e5,color:#fff,stroke:#4f46e5,stroke-width:2px style E fill:#059669,color:#fff

Lazy Controller Registration

Instead of registering all controllers upfront, you can register them on demand:

import { Application } from '@hotwired/stimulus';

const app = Application.start();

// Register eagerly (always loaded)
app.register('always-loaded', class extends Controller {
  connect() {
    console.log('Always available');
  }
});

// Lazy registration pattern
async function registerLazyController(name, modulePath) {
  try {
    const module = await import(modulePath);
    app.register(name, module.default);
    console.log(`Lazy loaded controller: ${name}`);
  } catch (error) {
    console.error(`Failed to load controller ${name}:`, error);
  }
}

// Register only when needed
document.addEventListener('click', async (event) => {
  if (event.target.matches('[data-controller~="heavy-editor"]')) {
    await registerLazyController('heavy-editor', './controllers/heavy_editor_controller.js');
  }
});

Teacher explains: Dynamic import() returns a promise. When the module loads, you call app.register() to make the controller available. Stimulus will automatically connect any matching elements that already exist on the page.

Turbo Integration

Turbo Drive navigates pages without full reloads, which means controllers need to handle turbo:load and turbo:before-cache events.

Using the Stimulus Turbo Adapter

import { Application } from '@hotwired/stimulus';
import * as Turbo from '@hotwired/turbo';

const app = Application.start();
app.debug = true;

Stimulus automatically reconnects controllers after Turbo page visits because it observes DOM mutations.

Turbo Frame Controllers

<turbo-frame id="user-profile" src="/users/1/profile">
  <div data-controller="loading">
    <p data-loading-target="message">Loading profile...</p>
  </div>
</turbo-frame>
app.register('loading', class extends Controller {
  static targets = ['message'];

  connect() {
    console.log('Loading controller connected');
  }

  showLoaded(data) {
    this.messageTarget.textContent = `Profile: ${data.name}`;
  }
});

When the Turbo Frame loads, its content replaces the frame. Stimulus automatically disconnects the old controllers and connects controllers in the new content.

Handling Turbo Navigation

app.register('navigation', class extends Controller {
  static targets = ['content'];

  connect() {
    this.handleTurboEvents();
  }

  handleTurboEvents() {
    document.addEventListener('turbo:before-cache', () => {
      this.saveScrollPosition();
      this.cleanupTimers();
    });

    document.addEventListener('turbo:load', () => {
      this.restoreScrollPosition();
      this.initPageSpecificBehavior();
    });
  }

  saveScrollPosition() {
    sessionStorage.setItem('scrollPos', window.scrollY);
  }

  restoreScrollPosition() {
    const pos = sessionStorage.getItem('scrollPos');
    if (pos) window.scrollTo(0, parseInt(pos));
  }

  cleanupTimers() {
    // Clear any intervals or timeouts
  }

  initPageSpecificBehavior() {
    console.log('Page initialized after Turbo navigation');
  }
});

Using stimulus-use

stimulus-use provides composable behaviors for common patterns.

Installation

npm install stimulus-use

useClickOutside

import { Controller } from '@hotwired/stimulus';
import { useClickOutside } from 'stimulus-use';

app.register('dropdown', class extends Controller {
  static targets = ['menu'];

  connect() {
    useClickOutside(this, { element: this.menuTarget });
  }

  clickOutside(event) {
    this.menuTarget.classList.add('hidden');
  }

  toggle() {
    this.menuTarget.classList.toggle('hidden');
  }
});

useDebounce

import { Controller } from '@hotwired/stimulus';
import { useDebounce } from 'stimulus-use';

app.register('search', class extends Controller {
  static targets = ['input'];
  static values = { delay: { type: Number, default: 300 } };

  connect() {
    useDebounce(this, { wait: this.delayValue });
  }

  search() {
    const query = this.inputTarget.value.trim();
    if (query.length >= 2) {
      this.performSearch(query);
    }
  }

  performSearch(query) {
    console.log(`Searching for: ${query}`);
  }
});

useIntersection

import { Controller } from '@hotwired/stimulus';
import { useIntersection } from 'stimulus-use';

app.register('lazy-image', class extends Controller {
  static targets = ['image'];

  connect() {
    useIntersection(this, { threshold: 0.1 });
  }

  appear() {
    const img = this.imageTarget;
    img.src = img.dataset.src;
    img.onload = () => img.classList.add('loaded');
  }
});
<div data-controller="lazy-image">
  <img data-lazy-image-target="image"
       data-src="/images/photo.webp"
       src="data:image/svg+xml,..."
       alt="Lazy loaded photo">
</div>

Dynamic Content Handling

Observing DOM Mutations

Stimulus uses a MutationObserver internally to detect new elements. When you add content dynamically, Stimulus automatically connects controllers on the new elements.

function insertDynamicContent(container, html) {
  container.insertAdjacentHTML('beforeend', html);
  // Stimulus automatically detects and connects controllers
}
<div data-controller="list">
  <div data-list-target="container">
    <!-- Static items here -->
  </div>
  <button data-action="click->list#loadMore">Load More</button>
</div>
app.register('list', class extends Controller {
  static targets = ['container'];
  static values = { page: { type: Number, default: 1 } };

  async loadMore() {
    this.pageValue++;
    const html = await this.fetchPage(this.pageValue);
    this.containerTarget.insertAdjacentHTML('beforeend', html);
    // New controllers in html are connected automatically
  }

  async fetchPage(page) {
    const response = await fetch(`/api/items?page=${page}`);
    return response.text();
  }
});

Infinite Scroll Pattern

import { Controller } from '@hotwired/stimulus';
import { useIntersection } from 'stimulus-use';

app.register('infinite-scroll', class extends Controller {
  static targets = ['container', 'sentinel'];
  static values = {
    page: { type: Number, default: 1 },
    loading: { type: Boolean, default: false },
    endReached: { type: Boolean, default: false }
  };

  connect() {
    useIntersection(this, { element: this.sentinelTarget, threshold: 0 });
  }

  appear() {
    if (!this.loadingValue && !this.endReachedValue) {
      this.loadMore();
    }
  }

  async loadMore() {
    this.loadingValue = true;
    this.pageValue++;

    try {
      const html = await this.fetchPage(this.pageValue);
      if (html.trim().length === 0) {
        this.endReachedValue = true;
        this.sentinelTarget.classList.add('hidden');
        return;
      }
      this.containerTarget.insertAdjacentHTML('beforeend', html);
    } catch (error) {
      console.error('Failed to load more:', error);
    } finally {
      this.loadingValue = false;
    }
  }

  async fetchPage(page) {
    const response = await fetch(`/api/items?page=${page}`);
    return response.text();
  }
});

Real-world: Modal with Dynamic Content

<div data-controller="modal-loader"
     data-modal-loader-url-value="/api/help/content">
  <button data-action="click->modal-loader#open">Help</button>

  <div data-modal-loader-target="container" class="hidden">
    <div data-controller="modal"
         data-modal-loader-outlet="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">
        <div data-modal-loader-target="spinner">Loading...</div>
      </div>
    </div>
  </div>
</div>
app.register('modal-loader', class extends Controller {
  static targets = ['container', 'spinner'];
  static outlets = ['modal'];
  static values = { url: String };

  async open() {
    this.containerTarget.classList.remove('hidden');

    try {
      const response = await fetch(this.urlValue);
      const html = await response.text();
      this.spinnerTarget.outerHTML = html;
      // The modal controller in the new HTML connects automatically
    } catch (error) {
      this.spinnerTarget.textContent = 'Failed to load content';
    }
  }
});

Common Mistakes

1. Not Handling Turbo Cache Properly

// ❌ Timers survive page cache
connect() {
  this.interval = setInterval(() => {}, 1000);
}
// ✅ Clear before cache
connect() {
  document.addEventListener('turbo:before-cache', () => {
    clearInterval(this.interval);
  });
}

2. Registering Controllers After Elements Exist

// If the element exists before registration, Stimulus won't connect it
// Register BEFORE the element appears, or use app.register() which
// retroactively scans the DOM for matching elements

3. Forgetting to Import stimulus-use Behaviors

// ❌ useIntersection is not available
connect() {
  useIntersection(this);
}
// ✅ Import it
import { useIntersection } from 'stimulus-use';

4. Loading Controllers Synchronously When Async Is Needed

// ❌ Blocks the main thread
import('./heavy_controller.js'); // Forgot await!
// ✅ Async import
const module = await import('./heavy_controller.js');

5. Not Handling Disconnect in Dynamic Content

// Always clean up in disconnect() to prevent memory leaks
disconnect() {
  clearInterval(this.#timer);
  this.#cleanup();
}

Practice Questions

1. How does Stimulus handle controllers on dynamically inserted HTML?

Stimulus uses a MutationObserver internally. When new HTML with data-controller attributes is inserted into the DOM, Stimulus automatically connects the corresponding controllers.

2. What events does Turbo fire that are relevant to Stimulus controllers?

turbo:before-cache (cleanup before cache), turbo:load (page loaded), turbo:frame-load (frame loaded), and turbo:frame-render (frame rendered).

3. How do you lazy-load a controller?

Use dynamic import() and call app.register() when the module loads. This defers loading the controller code until it's actually needed.

4. What is stimulus-use and what problem does it solve?

stimulus-use is a library of composable behaviors (click outside, debounce, intersection observer, etc.) that would otherwise require repetitive boilerplate code in each controller.

Challenge

Build a lazy-tabs controller that loads tab content dynamically via fetch. Each tab should only load its content when first clicked. Use a Turbo Frame for each tab's content area and lazy-load the frame source.

FAQ

### Does Stimulus work with Turbo Drive?

Yes. Stimulus integrates seamlessly with Turbo Drive. When Turbo navigates to a new page, Stimulus automatically disconnects old controllers and connects new ones based on the updated DOM.

How do I prevent memory leaks with dynamic content?

Always clear timers, cancel fetch requests, and remove event listeners in disconnect(). Use turbo:before-cache to clean up before Turbo caches a page.

Can I use Stimulus with HTMX?

Yes. HTMX adds dynamic content via AJAX, and Stimulus automatically picks up controllers in the new HTML. Both can coexist on the same page.

What is the best way to handle large controller files?

Use dynamic import() with app.register() to lazy-load controllers. Bundle your application with Webpack, Esbuild, or Vite to code-split controller files.

What's Next

Topic Description
{{< ref "stimulus-typescript" >}} Typing controllers, targets, values, and outlets with TypeScript
{{< ref "stimulus-testing" >}} Testing Stimulus controllers with Jest and DOM testing
{{< ref "stimulus-project" >}} Build a complete Stimulus application from scratch
Hotwire Turbo Learn Turbo Drive, Frames, and Streams for page navigation

Built by the developers of Doda Browser, DodaZIP, and Durga Antivirus Pro. This loading tutorial powers the dynamic panel loading and Turbo integration in the Doda Browser extension.

What's Next

Congratulations on completing this Stimulus Lazy Loading 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