Event Handlers to Drupal Behaviors
When to Use
Use this when converting React event handlers (
onClick,onChange,onSubmit, etc.) to Drupal's server-rendered architecture. React events have NO direct Twig equivalent. The translation requires separating markup (Twig) from behavior (JavaScript or server-side).
Decision: Event Handler Mapping
| React Event | Drupal Equivalent | When to Use |
|---|---|---|
onClick (toggle, show/hide) |
CSS-only (:checked, <details>) or data-* + Drupal.behaviors |
Prefer CSS-only for simple toggles; use behaviors for complex logic |
onClick (navigation) |
Standard <a href> link |
No JS needed -- server-side navigation |
onClick (API call) |
Drupal.behaviors + fetch() or HTMX hx-get |
Client-side API call or HTMX for HTML fragment |
onChange (form input) |
Drupal Form API #ajax callback |
Server processes change; returns updated markup |
onChange (live filter/search) |
Drupal.behaviors + debounced fetch, or HTMX hx-trigger="keyup changed delay:300ms" |
Client-side filtering or server-side with HTMX |
onSubmit |
Drupal Form API | Form submit is always server-side in Drupal |
onHover / onMouseEnter |
CSS :hover pseudo-class |
Almost never needs JS; pure CSS |
onFocus / onBlur |
CSS :focus / :focus-within or Drupal.behaviors |
CSS for visual states; JS for complex focus management |
onScroll |
Drupal.behaviors with IntersectionObserver |
No Twig involvement; pure JS behavior |
onKeyDown |
Drupal.behaviors with addEventListener('keydown') |
Accessibility handlers for keyboard navigation |
Pattern: CSS-Only Toggle
React
function Accordion({ title, children }) {
const [open, setOpen] = useState(false);
return (
<div>
<button onClick={() => setOpen(!open)}>{title}</button>
{open && <div>{children}</div>}
</div>
);
}
Twig (using HTML <details>)
<details {{ attributes.addClass('collapse') }}>
<summary class="collapse-title font-semibold">
{{ title }}
</summary>
<div class="collapse-content">
{{ content }}
</div>
</details>
The <details> / <summary> HTML elements provide native toggle behavior without any JavaScript.
Pattern: Data Attributes + Drupal Behavior
React
function Tabs({ items, defaultIndex = 0 }) {
const [active, setActive] = useState(defaultIndex);
return (
<div>
{items.map((item, i) => (
<button key={i} onClick={() => setActive(i)}
className={i === active ? 'tab-active' : ''}>
{item.label}
</button>
))}
<div>{items[active].content}</div>
</div>
);
}
Twig (markup with data attributes)
<div {{ attributes.addClass('tabs-container') }}
data-default-index="{{ default_index|default(0) }}">
<div class="tabs" role="tablist">
{% for item in items %}
<button class="tab" role="tab"
data-tab-index="{{ loop.index0 }}"
aria-selected="{{ loop.index0 == 0 ? 'true' : 'false' }}">
{{ item.label }}
</button>
{% endfor %}
</div>
{% for item in items %}
<div class="tab-panel" role="tabpanel"
data-panel-index="{{ loop.index0 }}"
{{ loop.index0 != 0 ? 'hidden' }}>
{{ item.content }}
</div>
{% endfor %}
</div>
JavaScript behavior (separate .js file in SDC)
(function (Drupal) {
Drupal.behaviors.tabsComponent = {
attach(context) {
const containers = once('tabs', '.tabs-container', context);
containers.forEach((container) => {
const tabs = container.querySelectorAll('[role="tab"]');
const panels = container.querySelectorAll('[role="tabpanel"]');
tabs.forEach((tab) => {
tab.addEventListener('click', () => {
const index = tab.dataset.tabIndex;
tabs.forEach(t => t.setAttribute('aria-selected', 'false'));
tab.setAttribute('aria-selected', 'true');
panels.forEach(p => p.hidden = p.dataset.panelIndex !== index);
});
});
});
},
};
})(Drupal);
Key Principle
The separation of concerns in Drupal is absolute:
- Twig renders the initial HTML structure with semantic markup, ARIA attributes, and
data-*attributes - JavaScript (via
Drupal.behaviors) attaches interactivity after render - CSS handles visual state changes (
:hover,:focus,:checked,[aria-selected="true"])
Common Mistakes
- Wrong: Adding
onclickinline handlers in Twig for complex logic → Right: Usedata-*attributes andDrupal.behaviorsinstead - Wrong: Trying to replicate React's
useStatein Twig → Right: Twig is server-rendered once; client state lives in JavaScript - Wrong: Forgetting to use
once()in behaviors → Right:Drupal.behaviors.attachruns on every AJAX response; withoutonce(), event listeners accumulate