Skip to content

Ember Actions — Event Handling and User Interactions

DodaTech Updated 2026-06-28 6 min read

In this tutorial, you will learn about Ember Actions. We cover key concepts, practical examples, and best practices to help you master this topic.

Ember actions handle user interactions in templates. The {{on}} modifier binds DOM events to component methods. The @action decorator defines methods that maintain the correct this context. Actions can be passed between components through args.

What You'll Learn

You will learn how to handle DOM events with {{on}}, define actions with @action, pass actions between components, handle form submissions, and prevent event bubbling.

Why It Matters

Proper event handling makes applications interactive. Ember's action system ensures consistent this context, prevents memory leaks through modifier cleanup, and keeps event logic in dedicated methods.

Real-World Use

A data entry form handles input changes, form submission, field validation, and autocomplete. Each event is handled by a dedicated action method, making the code organized and testable.

flowchart LR
    A[DOM Event] --> B[{{on}} modifier]
    B --> C[@action method]
    C --> D[Update tracked state]
    C --> E[Call parent action]
    E --> F[Parent component/route]

Basic Event Handling with {{on}}

Use the {{on}} modifier to bind DOM events.

{{! app/components/click-counter.hbs }}
<button type="button" {{on "click" this.increment}}>
  Clicked {{this.count}} times
</button>
<button type="button" {{on "click" this.reset}}>
  Reset
</button>
// app/components/click-counter.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ClickCounterComponent extends Component {
  @tracked count = 0;

  @action
  increment() {
    this.count++;
  }

  @action
  reset() {
    this.count = 0;
  }
}

Passing Actions to Child Components

Parent components pass action functions as arguments.

// app/components/parent-component.js
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class ParentComponent extends Component {
  @action
  handleChildClick(data) {
    console.log('Received from child:', data);
  }

  @action
  handleChildSubmit(formData) {
    console.log('Form submitted:', formData);
  }
}
{{! Child component invocation }}
<ChildButton @onClick={{this.handleChildClick}} />
<ChildForm @onSubmit={{this.handleChildSubmit}} />
// app/components/child-button.js
import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class ChildButtonComponent extends Component {
  @action
  onClick() {
    if (this.args.onClick) {
      this.args.onClick({ timestamp: Date.now() });
    }
  }
}

Form Handling

// app/components/login-form.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class LoginFormComponent extends Component {
  @tracked email = '';
  @tracked password = '';
  @tracked error = '';
  @tracked isSubmitting = false;

  get isFormValid() {
    return this.email.length > 0 && this.password.length >= 6;
  }

  @action
  updateEmail(event) {
    this.email = event.target.value;
    this.error = '';
  }

  @action
  updatePassword(event) {
    this.password = event.target.value;
    this.error = '';
  }

  @action
  async handleSubmit(event) {
    event.preventDefault();

    if (!this.isFormValid) {
      this.error = 'Please fill in all fields';
      return;
    }

    this.isSubmitting = true;
    this.error = '';

    try {
      await this.args.onSubmit({
        email: this.email,
        password: this.password
      });
    } catch (error) {
      this.error = error.message || 'Login failed';
    } finally {
      this.isSubmitting = false;
    }
  }
}
{{! app/components/login-form.hbs }}
<form {{on "submit" this.handleSubmit}}>
  {{#if this.error}}
    <div class="error" role="alert">{{this.error}}</div>
  {{/if}}

  <div class="form-field">
    <label for="email">Email</label>
    <input
      id="email"
      type="email"
      value={{this.email}}
      {{on "input" this.updateEmail}}
      disabled={{this.isSubmitting}}
    />
  </div>

  <div class="form-field">
    <label for="password">Password</label>
    <input
      id="password"
      type="password"
      value={{this.password}}
      {{on "input" this.updatePassword}}
      disabled={{this.isSubmitting}}
    />
  </div>

  <button type="submit" disabled={{or (not this.isFormValid) this.isSubmitting}}>
    {{if this.isSubmitting "Logging in..." "Login"}}
  </button>
</form>

Event Modifiers and Options

The {{on}} modifier supports event options.

{{! Passive event (for scroll performance) }}
<div {{on "scroll" this.handleScroll passive=true}}>

{{! Capture phase }}
<div {{on "click" this.handleOuterClick capture=true}}>

{{! Once — fires only once }}
<button {{on "click" this.handleFirstClick once=true}}>

{{! Prevent default automatically }}
<form {{on "submit" this.handleSubmit preventDefault=true}}>

Keyboard Events

// app/components/search-input.js
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class SearchInputComponent extends Component {
  @tracked query = '';
  @tracked selectedIndex = -1;

  @action
  handleInput(event) {
    this.query = event.target.value;
    this.selectedIndex = -1;
    this.args.onQueryChange?.(this.query);
  }

  @action
  handleKeydown(event) {
    if (event.key === 'Escape') {
      this.query = '';
      this.args.onQueryChange?.('');
      event.target.blur();
    }

    if (event.key === 'ArrowDown') {
      event.preventDefault();
      this.selectedIndex++;
      this.args.onHighlightChange?.(this.selectedIndex);
    }

    if (event.key === 'ArrowUp') {
      event.preventDefault();
      this.selectedIndex = Math.max(-1, this.selectedIndex - 1);
      this.args.onHighlightChange?.(this.selectedIndex);
    }

    if (event.key === 'Enter' && this.selectedIndex >= 0) {
      event.preventDefault();
      this.args.onSelect?.(this.selectedIndex);
    }
  }

  @action
  handleFocus() {
    this.args.onFocus?.();
  }

  @action
  handleBlur() {
    // Delay to allow click on suggestion
    setTimeout(() => {
      this.args.onBlur?.();
    }, 200);
  }
}

Multiple Event Handlers

Multiple {{on}} modifiers can target the same event.

<button
  type="button"
  {{on "click" this.logClick}}
  {{on "click" this.trackAnalytics}}
  {{on "click" this.handleAction}}
>
  Click me
</button>

Preventing Default Behavior

// app/components/link-button.js
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { inject as service } from '@ember/service';

export default class LinkButtonComponent extends Component {
  @service router;

  @action
  handleClick(event) {
    event.preventDefault();
    event.stopPropagation();

    this.router.transitionTo(this.args.route, this.args.model);
  }
}

Action Modifier for Custom Events

The {{on}} modifier works with custom DOM events too.

<video
  {{on "play" this.onPlay}}
  {{on "pause" this.onPause}}
  {{on "timeupdate" this.onTimeUpdate}}
  {{on "ended" this.onEnded}}
  controls
>
  <source src={{@src}} type="video/mp4" />
</video>

Common Mistakes

  1. Forgetting @action decorator on methods. Without @action, this may be wrong when the method is called asynchronously.
  2. Calling event.preventDefault() manually instead of using preventDefault=true. The preventDefault option is cleaner and less error-prone.
  3. Not cleaning up event listeners added outside the template. {{on}} cleans up automatically. Manual addEventListener requires manual removeEventListener in willDestroy.
  4. Passing action results instead of action references. {{on "click" this.handleClick()}} calls the function immediately. Use this.handleClick without parentheses.
  5. Creating new functions in the template. {{on "click" (fn this.handleClick @id)}} is fine. {{on "click" (fn (action this.handleClick) @id)}} with deprecated action helper is not.

Practice Questions

  1. How do you bind a click event to a component action?
  2. What does the @action decorator do?
  3. How do you pass an action from parent to child?
  4. What event options does {{on}} support?
  5. Challenge: Create an autocomplete search component with: text input, dropdown suggestions, keyboard navigation (arrow up/down, enter to select, escape to close), debounced API calls, and blur handling. Use {{on}} for all event bindings. Pass actions for selection and query change.

FAQ

What is the difference between `{{action}}` and `{{on}}`?

{{on}} is the modern way. {{action}} is deprecated. Use {{on}} for all new code.

Can I use `{{on}}` with window events?

Use {{modifier}} for custom element modifiers or handle window events in did-insert with cleanup in willDestroy.

{{< faq "How do I pass the event object to an action?" "The event is the first argument automatically. `{{on \"click\" this.handle}}` receives the event." >}}
Can I have multiple actions on the same event?

Yes. Add multiple {{on}} modifiers. They run in order.

How do I stop event propagation?

Call event.stopPropagation() in the action method or pass stopPropagation=true to the modifier.

Mini Project

Create a complex form with: (1) Input validation on blur and on input. (2) Character count for textarea. (3) Tags input (type comma-separated values). (4) Auto-save on Ctrl+S keyboard shortcut. (5) Dirty state tracking with confirmation on navigation away. Use {{on}} for all event bindings. Use @action for all handlers.

What's Next

Now that you understand actions, learn Ember Testing for testing Ember applications. Then explore Ember Octane for modern Ember features.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro