The once() API
apps/docs/src/content/docs/drupal/behaviors/once-api Click to copy apps/docs/src/content/docs/drupal/behaviors/once-api The once() function is the mechanism Drupal uses to guarantee that a JavaScript initialization runs exactly once per element, regardless of how many times Drupal.behaviors.attach() is called. It is a required part of every Drupal.behaviors implementation that manipulates the DOM.
Why once() Exists
Section titled “Why once() Exists”Drupal.behaviors.attach() is called multiple times throughout a page’s lifecycle: on initial page load, after every AJAX response, after BigPipe streams content, and whenever any code calls Drupal.attachBehaviors(). Without once(), every call to attach() would re-process elements that were already initialized.
The Problem Without once()
Section titled “The Problem Without once()”// WITHOUT once() — broken patternDrupal.behaviors.hxButtons = { attach(context, settings) { // This query finds ALL hx-button elements, not just new ones context.querySelectorAll('hx-button').forEach((button) => { button.addEventListener('click', handleClick); }); },};After three attach() calls on the same element:
- The element has three
clicklisteners - The handler fires three times per click
- Memory leaks grow with each AJAX response
The Solution
Section titled “The Solution”// WITH once() — correct patternDrupal.behaviors.hxButtons = { attach(context, settings) { once('mysite:button-tracker', 'hx-button', context).forEach((button) => { button.addEventListener('click', handleClick); }); },};once() marks each element it processes. On subsequent calls with the same key, already-marked elements are excluded from the returned array. The click listener is added exactly once per element.
The once() Function Signature
Section titled “The once() Function Signature”once(id, selector, context)| Parameter | Type | Description |
|---|---|---|
id | string | Unique key identifying this initialization. Elements are marked with this key to prevent re-processing. |
selector | string | Element | NodeList | Array<Element> | CSS selector string, single DOM element, NodeList, or array of elements to process. |
context | Element or Document | Root element to search within. Always pass the context from Drupal.behaviors.attach(). |
Returns: an array of elements that had not yet been marked with id. If all elements were already marked, returns an empty array.
Namespaced Key Format
Section titled “Namespaced Key Format”The shipped @helixui/drupal-behaviors package uses hx-* once IDs (e.g. hx-dialog, hx-tooltip) to namespace its keys. For project-local behaviors that build on top of HELiX components, pick a distinct project namespace (e.g. mysite:patient-card-init, clinic:appointment-toolbar) so your keys never collide with the package’s.
// Correct namespacingonce('mysite:card-init', 'hx-card', context)once('mysite:button-tracker', 'hx-button', context)once('mysite:data-table-columns', 'hx-data-table[data-columns]', context)once('mysite:alert-dismiss', 'hx-alert[dismissible]', context)once('mysite:patient-list-enhance', '.patient-list hx-card', context)once('mysite:form-validation', 'form[data-helix-validate]', context)
// Avoid: generic names without namespaceonce('init', 'hx-card', context) // Too genericonce('card', 'hx-card', context) // No namespaceonce('hx-card-init', 'hx-card', context) // Collides with @helixui/drupal-behaviors hx-* keysMultiple Behaviors on the Same Element
Section titled “Multiple Behaviors on the Same Element”Different behaviors on the same element each get their own key. Each key is tracked independently:
// First behavior: tracks clicksonce('mysite:card-click', 'hx-card', context).forEach((card) => { card.addEventListener('hx-click', handleCardClick);});
// Second behavior: tracks impressionsonce('mysite:card-impression', 'hx-card', context).forEach((card) => { observeCardVisibility(card);});If new cards are added via AJAX, both behaviors run on the new cards and neither re-runs on the existing cards.
Passing context
Section titled “Passing context”Always pass the context parameter from attach() as the third argument to once(). The context parameter is the DOM subtree of newly added content.
Drupal.behaviors.hxInit = { attach(context, settings) { // Correct: scoped to context once('mysite:init', 'hx-card', context).forEach((card) => { // ... });
// Wrong: queries the entire document once('mysite:init', 'hx-card', document).forEach((card) => { // This works but defeats the purpose of context scoping. // It will also fail to find elements added to a modal or specific region. }); },};On initial page load, context is document. On AJAX responses, context is the inserted container. Using context consistently ensures once() only processes newly arrived elements.
Checking once() State with once.filter()
Section titled “Checking once() State with once.filter()”once.filter() returns the subset of elements that have already been marked with a given key, without marking any new ones. Use it to check initialization state without triggering initialization.
// Check which cards are already initializedconst initializedCards = once.filter('mysite:card-init', document.querySelectorAll('hx-card'));console.log(`${initializedCards.length} cards are already initialized`);
// Check if a specific element is initialized — guard against missing element// since once.filter() on `[null]` will throw.const card = document.querySelector('#patient-card-123');const isInitialized = card !== null && once.filter('mysite:card-init', [card]).length > 0;Removing once() Marks with once.remove()
Section titled “Removing once() Marks with once.remove()”once.remove() strips the marks from elements, allowing once() to process them again. This is needed for cleanup in detach() when the element will be re-initialized later.
Drupal.behaviors.hxModalContent = { attach(context, settings) { once('mysite:modal-init', '.modal hx-card', context).forEach((card) => { initializeModalCard(card); }); },
detach(context, settings, trigger) { // When the modal closes, remove once marks so the content // can be re-initialized when the modal opens again once.remove('mysite:modal-init', context.querySelectorAll('.modal hx-card'), context); },};once.remove() Signature
Section titled “once.remove() Signature”once.remove(id, selector, context)once.remove() accepts the same parameter shape as once() — the optional context (Element or Document) scopes the search so you only strip marks under the node you’re cleaning up. Pass the context from detach() to limit cleanup to the unloading subtree.
Drupal 9/10/11 Compatibility
Section titled “Drupal 9/10/11 Compatibility”The once Global (Drupal 9.2+)
Section titled “The once Global (Drupal 9.2+)”Since Drupal 9.2, Drupal ships a standalone core/once library that provides the once global function. This is the correct API for Drupal 10 and 11.
Declare core/once as a dependency in your mytheme.libraries.yml:
helix-behaviors: js: js/behaviors/hx-card-init.js: {} dependencies: - core/drupal - core/onceUse it in the IIFE:
(function (Drupal, once) { 'use strict';
Drupal.behaviors.hxCardInit = { attach(context, settings) { once('mysite:card-init', 'hx-card', context).forEach((card) => { // ... }); }, };
})(Drupal, once);jQuery $.once() (Legacy — Drupal 7 / early Drupal 8)
Section titled “jQuery $.once() (Legacy — Drupal 7 / early Drupal 8)”Older Drupal code used jQuery’s $.once() plugin. That plugin was removed from Drupal core in 10.x — it is not available in Drupal 10 or 11 at all. (The unrelated drupal/jquery_ui package does not provide $.once().) Do not use $.once() in new code.
// OLD — jQuery.once() — DO NOT USE in Drupal 10/11$(context).find('hx-card').once('hx-card-init').each(function () { // ...});
// NEW — standalone once() — use thisonce('mysite:card-init', 'hx-card', context).forEach((card) => { // ...});Common Patterns
Section titled “Common Patterns”Initializing with a CSS Selector String
Section titled “Initializing with a CSS Selector String”// Most common form: selector stringonce('mysite:card-click', 'hx-card[hx-href]', context).forEach((card) => { card.addEventListener('hx-click', () => { window.location.href = card.getAttribute('href'); });});Initializing a Single Element
Section titled “Initializing a Single Element”// Single element: wrap in an array or use a NodeListconst mainNav = context.querySelector('.main-navigation hx-button');if (mainNav) { once('mysite:nav-button', [mainNav]).forEach((button) => { button.addEventListener('hx-click', toggleNavigation); });}Initializing the context Element Itself
Section titled “Initializing the context Element Itself”// When the context element IS what you want to initializeonce('mysite:region-init', context === document ? document.body : context).forEach((el) => { el.setAttribute('data-helix-region-ready', 'true');});Filtering Elements Before Processing
Section titled “Filtering Elements Before Processing”// only initialize cards that have an entity IDonce('mysite:patient-card', 'hx-card', context) .filter((card) => card.hasAttribute('data-entity-id')) .forEach((card) => { initializePatientCard(card); });once() Implementation Details
Section titled “once() Implementation Details”once() stores initialization state by setting a custom DOM attribute on each element. The attribute name is derived from the id parameter.
<!-- Before once() --><hx-card variant="default">...</hx-card>
<!-- After once('mysite:card-init', 'hx-card', context) --><hx-card variant="default" data-once="mysite:card-init">...</hx-card>The attribute is data-once, and its value is a space-separated list of all once keys that have been applied to the element. Drupal’s once() stores the id verbatim — there is no special colon-to-hyphen serialization step. (Older revisions of these docs implied otherwise; the runtime preserves the id as-is.)
Knowing this lets you:
- Inspect initialization state in browser DevTools
- Write CSS selectors that target initialized elements
- Verify that
once.remove()worked correctly
// Check initialization state in browser consoleconst card = document.querySelector('hx-card');console.log('once data:', card.getAttribute('data-once'));// "helixui-card-init helixui-card-click"Testing once() Behavior
Section titled “Testing once() Behavior”Manual Test: AJAX Response
Section titled “Manual Test: AJAX Response”To verify your behavior handles AJAX correctly:
- Load the page and inspect
data-onceattributes on your elements. - Trigger an AJAX response (e.g., click a Views pager link).
- Inspect newly added elements — they should have
data-onceafter the Behavior runs. - Inspect the original elements — their
data-onceattribute should not have changed (no duplicate initialization).
Automated Test Approach
Section titled “Automated Test Approach”// Simulating multiple attach() calls in a test// (Using plain DOM, no test framework dependency shown)
const container = document.createElement('div');container.innerHTML = '<hx-card data-test></hx-card>';document.body.appendChild(container);
let callCount = 0;Drupal.behaviors.hxTestBehavior = { attach(context, settings) { once('mysite:test', 'hx-card[data-test]', context).forEach((card) => { callCount++; }); },};
// First callDrupal.behaviors.hxTestBehavior.attach(container, {});console.assert(callCount === 1, 'Should initialize once');
// Second call — same contextDrupal.behaviors.hxTestBehavior.attach(container, {});console.assert(callCount === 1, 'Should not re-initialize');
// Third call — new container with same contentconst container2 = document.createElement('div');container2.innerHTML = '<hx-card data-test></hx-card>';document.body.appendChild(container2);Drupal.behaviors.hxTestBehavior.attach(container2, {});console.assert(callCount === 2, 'Should initialize new element');once() Quick Reference
Section titled “once() Quick Reference”| Function | Purpose |
|---|---|
once(id, selector, context) | Find elements matching selector in context that have not been marked with id; mark them; return them. |
once.filter(id, elements) | Return the subset of elements already marked with id. Does not mark anything new. |
once.remove(id, elements) | Remove the id mark from elements, allowing them to be processed by once() again. |
once.find(id, context) | Return all elements in context that are marked with id. Equivalent to once.filter(id, context.querySelectorAll('[data-once]')). |