Skip to content

Production Patterns

When to Use

You need proven patterns for common HTMX use cases: load more, modals, infinite scroll, real-time updates.

Pattern: Load More / Pagination

Append new content to existing list:

// Controller
public function loadMore(int $page = 0) {
  $items = $this->getItems($page);

  $build['items'] = [
    '#theme' => 'item_list',
    '#items' => $items,
  ];

  if ($this->hasMoreItems($page)) {
    $build['load_more'] = [
      '#type' => 'html_tag',
      '#tag' => 'button',
      '#value' => 'Load More',
    ];

    (new Htmx())
      ->get(Url::fromRoute('my.load_more', ['page' => $page + 1]))
      ->target('#items-container')
      ->swap('beforeend')              // Append to container
      ->select('#items-container > *') // Extract only new items
      ->applyTo($build['load_more']);
  }

  return $build;
}

Pattern: Infinite Scroll

Trigger on scroll reveal:

$build['sentinel'] = [
  '#type' => 'html_tag',
  '#tag' => 'div',
  '#attributes' => ['id' => 'scroll-sentinel'],
];

(new Htmx())
  ->get(Url::fromRoute('my.load_more', ['page' => $page + 1]))
  ->trigger('revealed')              // Fire when scrolled into view
  ->target('#items-container')
  ->swap('beforeend')
  ->select('#items-container > *')
  ->applyTo($build['sentinel']);

Pattern: Modal/Dialog Content

Load content into dialog on demand:

// Button that opens modal
$build['open_modal'] = [
  '#type' => 'html_tag',
  '#tag' => 'button',
  '#value' => 'Open Details',
];

(new Htmx())
  ->get(Url::fromRoute('my.modal_content', ['id' => $entity_id]))
  ->target('#modal-content')
  ->swap('innerHTML')
  ->onlyMainContent()
  ->applyTo($build['open_modal']);

// Modal container (already on page)
$build['modal'] = [
  '#type' => 'container',
  '#attributes' => ['id' => 'modal-content', 'class' => ['modal']],
];

Pattern: Real-Time Updates (Polling)

Poll for updates at intervals:

$build['status'] = [
  '#type' => 'container',
  '#attributes' => ['id' => 'status-display'],
  '#markup' => $this->getCurrentStatus(),
];

(new Htmx())
  ->get(Url::fromRoute('my.status'))
  ->trigger('every 5s')              // Poll every 5 seconds
  ->target('#status-display')
  ->swap('outerHTML')
  ->onlyMainContent()
  ->applyTo($build['status']);

Update results as user types:

$form['search'] = [
  '#type' => 'textfield',
  '#title' => 'Search',
];

(new Htmx())
  ->get(Url::fromRoute('my.search'))
  ->trigger('keyup changed delay:500ms')  // Debounce typing
  ->target('#search-results')
  ->swap('innerHTML')
  ->include('[name="search"]')            // Include search field value
  ->indicator('#spinner')                 // Show loading indicator
  ->onlyMainContent()
  ->applyTo($form['search']);

$form['results'] = [
  '#type' => 'container',
  '#attributes' => ['id' => 'search-results'],
  '#markup' => $this->getSearchResults($search_term),
];

Common Mistakes

  • Not using swap('beforeend') for append operations — innerHTML replaces instead of appending
  • Forgetting select() for load more — Entire response (including button) gets appended
  • Not debouncing live search — Creates too many requests while typing
  • Using polling without considering server load — Combine with cache strategy
  • Not providing loading indicators — Users don't know request is in progress

See Also