JavaScript behaviors
A plain DOMContentLoaded listener runs once, when the page first loads. Drupal pages keep changing after that: Ajax replaces form fragments, dialogs inject content, BigPipe (core’s module that streams slow page sections in after the initial response) streams sections in late. Behaviors are Drupal’s contract for JavaScript that keeps working through all of it.
Write JavaScript that initializes correctly on first load and again every time Drupal adds content to the page, exactly once per element, with server-side data available to it. This is the pattern for all interface JavaScript in modules and themes; the only scripts exempt from it are ones that never touch the DOM.
Prerequisites
Section titled “Prerequisites”- An asset library to put the JavaScript in; see Asset libraries.
- The library must depend on
core/drupal(definesDrupal.behaviorsand runs them) andcore/once.
Understand why behaviors exist
Section titled “Understand why behaviors exist”Core’s drupal.js defines Drupal.attachBehaviors(context, settings), which calls the attach method of every object registered under Drupal.behaviors. Core calls it for you:
- once on initial page load, with
contextset to the wholedocument(fromdrupal.init.js), and - after every Ajax insertion, with
contextset to only the newly added element (from core’s Ajax system).
So your attach runs many times per page lifetime, on overlapping pieces of DOM. The two consequences drive the whole pattern: always query inside context instead of the whole document, and make attach idempotent so reprocessing the same element does nothing. Errors are isolated too; one failing behavior does not stop the others.
Register a behavior with once()
Section titled “Register a behavior with once()”A minimal behavior from the first theme: it wires up collapsible cards, and the once() call is what makes it safe to run repeatedly:
/** * @file * Collapsible card behavior for the First theme. */
((Drupal, once) => { /** * Lets visitors collapse and expand collapsible cards. * * Behaviors run on initial page load and again for content added by * Ajax — "context" is only the new part of the page. once() marks * elements as processed so the same card is never wired up twice. * * @type {Drupal~behavior} */ Drupal.behaviors.firstThemeCollapsibleCard = { attach(context) { once('collapsible-card', '.card--collapsible', context).forEach( (card) => { const heading = card.querySelector('.card__heading'); heading.addEventListener('click', () => { card.classList.toggle('is-collapsed'); }); }, ); }, };})(Drupal, once);The pieces:
Drupal.behaviors.firstThemeCollapsibleCardregisters the behavior; the property name only has to be unique across the page.once('collapsible-card', '.card--collapsible', context)returns the matching elements insidecontextthat have not been processed under the idcollapsible-cardyet, and marks them. The second run returns an empty list, so listeners are never bound twice.- The file wraps everything in an immediately invoked function so it works regardless of what other libraries define globally.
once is its own core library (core/once), a small standalone DOM utility; it replaced the old jQuery jQuery.once() plugin, and it works on plain elements with no jQuery involved.
Pass server-side data with drupalSettings
Section titled “Pass server-side data with drupalSettings”PHP attaches settings alongside libraries; the browser receives them as one JSON object. Attach values wherever you attach the library:
/** * Implements hook_page_attachments(). * * Attaches an asset library on every page, and passes a PHP value to * JavaScript through drupalSettings. The settings only reach the browser * because the attached library depends on core/drupal, which loads the * drupalSettings bridge. */#[Hook('page_attachments')]public function pageAttachments(array &$attachments): void { $attachments['#attached']['library'][] = 'docs_snippets/reading_time'; $attachments['#attached']['drupalSettings']['docsSnippets']['readingTime'] = [ 'wordsPerMinute' => 225, ];}In the behavior, read them from the second argument rather than the drupalSettings global; Ajax responses can hand attach an updated settings object scoped to the request:
/** * @file * Adds an estimated reading time above node content. */
((Drupal, once) => { Drupal.behaviors.docsSnippetsReadingTime = { attach(context, settings) { // drupalSettings values attached in PHP arrive as the second argument. const { wordsPerMinute } = settings.docsSnippets.readingTime; // once() returns only elements not yet processed by this id, so the // badge is never added twice even when attach() runs again after an // Ajax update. once('reading-time', '.node__content', context).forEach((content) => { const words = content.textContent.trim().split(/\s+/).length; const minutes = Math.max(1, Math.round(words / wordsPerMinute)); const badge = document.createElement('p'); badge.className = 'reading-time'; badge.textContent = Drupal.formatPlural( minutes, '1 minute read', '@count minute read', ); content.prepend(badge); }); }, detach(context, settings, trigger) { // Undo what attach() did when the element leaves the page, so a // later re-attach starts clean. once.remove() clears the marker. if (trigger === 'unload') { once .remove('reading-time', '.node__content', context) .forEach((content) => { content.querySelector('.reading-time')?.remove(); }); } }, };})(Drupal, once);Settings are for data, not for markup, and they are emitted into the page unescaped-by-you: treat anything user-entered that you put in settings as untrusted when it comes back out in JS.
Clean up with detach
Section titled “Clean up with detach”Drupal.detachBehaviors(context, settings, trigger) is the mirror image: core calls it before content is removed (trigger = 'unload'), moved ('move'), or serialized for submission ('serialize'). Implement detach when your behavior holds resources that outlive the element: global event listeners, timers, observers. The reading-time behavior above shows the shape, including once.remove(), which unmarks elements so a future attach can process them again.
Most behaviors that only bind listeners to the element itself need no detach: the listeners are garbage-collected with the element.
Verify
Section titled “Verify”- Attach the library, run
ddev drush cache:rebuild(ddev drush cr), and load an article page: the reading-time badge from the example appears above the content. - In the browser console,
Drupal.behaviorslists your behavior by name, anddrupalSettings.docsSnippets.readingTimeshows the attached settings. - Confirm idempotency: run
Drupal.attachBehaviors(document, drupalSettings)in the console. Nothing visible should change; a second badge appearing means a missing or wrongonce()id. - Confirm Ajax safety where applicable: trigger any Ajax interaction that inserts matching elements (a Views pager with Ajax enabled, for example) and check the new elements get processed.
Variations
Section titled “Variations”With jQuery
Section titled “With jQuery”jQuery is not loaded unless something asks for it. If your script uses it, depend on core/jquery and accept that core ships jQuery 4; once() returns plain elements, so wrap them ($(element)) where needed.
JavaScript inside single-directory components
Section titled “JavaScript inside single-directory components”A COMPONENT.js file in a component directory is attached automatically when the component renders, but its auto-generated library has no dependencies by default. Declare core/drupal and core/once under the component’s libraryOverrides key so the behavior contract holds; see Single-directory components.
Settings for the whole page versus per element
Section titled “Settings for the whole page versus per element”drupalSettings is one global bag per response, which fits page-level data. For per-element data, prefer data-* attributes on the element itself and read them in attach; that keeps the element self-describing and survives the element being moved.
Related
Section titled “Related”- Asset libraries covers defining and attaching the library that carries your JS.
- Build your first theme 5: JavaScript behaviors with once() builds the collapsible-card behavior step by step.
hook_js_settings_alter()lets PHP alter the final settings object late, andhook_js_alter()the JS file list itself.
Was this page helpful?
Drupal is a registered trademark of Dries Buytaert.