Files
marco/.agents/skills/ember-best-practices/rules/performance-on-modifier-vs-handlers.md

5.8 KiB

title, impact, impactDescription, tags
title impact impactDescription tags
Use {{on}} Modifier Instead of Event Handler Properties MEDIUM Better performance and clearer event handling performance, events, modifiers, best-practices

Use {{on}} Modifier Instead of Event Handler Properties

Always use the {{on}} modifier for event handling instead of HTML event handler properties. The {{on}} modifier provides better memory management, automatic cleanup, and clearer intent.

Why {{on}} is Better:

  • Automatic cleanup when element is removed (prevents memory leaks)
  • Supports event options (capture, passive, once)
  • More explicit and searchable in templates

Incorrect (HTML event properties):

// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class Button extends Component {
  @action
  handleClick() {
    console.log('clicked');
  }

  <template>
    <button onclick={{this.handleClick}}>
      Click Me
    </button>
  </template>
}

Correct ({{on}} modifier):

// app/components/button.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class Button extends Component {
  @action
  handleClick() {
    console.log('clicked');
  }

  <template>
    <button {{on "click" this.handleClick}}>
      Click Me
    </button>
  </template>
}

Event Options

The {{on}} modifier supports standard event listener options:

// app/components/scrollable.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class Scrollable extends Component {
  @action
  handleScroll(event) {
    console.log('scrolled', event.target.scrollTop);
  }

  <template>
    {{! passive: true improves scroll performance }}
    <div {{on "scroll" this.handleScroll passive=true}}>
      {{yield}}
    </div>
  </template>
}

Available options:

  • capture - Use capture phase instead of bubble phase
  • once - Remove listener after first invocation
  • passive - Indicates handler won't call preventDefault() (better scroll performance)

Handling Multiple Events

// app/components/input-field.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class InputField extends Component {
  @action
  handleFocus() {
    console.log('focused');
  }

  @action
  handleBlur() {
    console.log('blurred');
  }

  @action
  handleInput(event) {
    this.args.onChange?.(event.target.value);
  }

  <template>
    <input
      type="text"
      value={{@value}}
      {{on "focus" this.handleFocus}}
      {{on "blur" this.handleBlur}}
      {{on "input" this.handleInput}}
    />
  </template>
}

Preventing Default and Stopping Propagation

Handle these in your action, not in the template:

// app/components/form.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class Form extends Component {
  @action
  handleSubmit(event) {
    event.preventDefault(); // Prevent page reload
    event.stopPropagation(); // Stop event bubbling if needed

    this.args.onSubmit?.(/* form data */);
  }

  <template>
    <form {{on "submit" this.handleSubmit}}>
      <button type="submit">Submit</button>
    </form>
  </template>
}

Keyboard Events

// app/components/keyboard-nav.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class KeyboardNav extends Component {
  @action
  handleKeyDown(event) {
    if (event.key === 'Enter' || event.key === ' ') {
      event.preventDefault();
      this.args.onActivate?.();
    }

    if (event.key === 'Escape') {
      this.args.onCancel?.();
    }
  }

  <template>
    <div role="button" tabindex="0" {{on "keydown" this.handleKeyDown}}>
      {{yield}}
    </div>
  </template>
}

Performance Tip: Event Delegation

For lists with many items, use event delegation on the parent:

// app/components/todo-list.gjs
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { on } from '@ember/modifier';

export default class TodoList extends Component {
  @action
  handleClick(event) {
    // Find which todo was clicked
    const todoId = event.target.closest('[data-todo-id]')?.dataset.todoId;
    if (todoId) {
      this.args.onTodoClick?.(todoId);
    }
  }

  <template>
    {{! Single listener for all todos - better than one per item }}
    <ul {{on "click" this.handleClick}}>
      {{#each @todos as |todo|}}
        <li data-todo-id={{todo.id}}>
          {{todo.title}}
        </li>
      {{/each}}
    </ul>
  </template>
}

Common Pitfalls

Don't bind directly without @action:

// This won't work - loses 'this' context
<button {{on "click" this.myMethod}}>Bad</button>

Use @action decorator:

@action
myMethod() {
  // 'this' is correctly bound
}

<button {{on "click" this.myMethod}}>Good</button>

Don't use string event handlers:

{{! Security risk and doesn't work in strict mode }}
<button onclick="handleClick()">Bad</button>

Always use the {{on}} modifier for cleaner, safer, and more performant event handling in Ember applications.

References: