Patterns for Actions

Actions are the primary method for updating state in an Ember application, and as such they have lots of uses and patterns. This guide covers some of the more common action patterns that can be used in Ember.

Action Fundamentals

Imagine we're building an application where users can have accounts. We need to build the UI for users to delete their account. Because we don't want users to accidentally delete their accounts, we'll build a button that requires the user to confirm in order to trigger some action.

We'll call this the ButtonWithConfirmation component. We can start off with a normal component definition, like we've seen before:

<button type="button">{{@text}}</button>

{{#if this.isConfirming}}
  <div class="confirm-dialog">
    <button type="button" class="confirm-submit">
      OK
    </button>
    <button type="button" class="confirm-cancel">
      Cancel
    </button>
  </div>
{{/if}}
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';

export default class ButtonWithConfirmationComponent extends Component {
  @tracked isConfirming = false;
}

Now we have a button that can receive some text as an argument, with a modal confirmation that will show conditionally based on its isConfirming property. You'll notice this property is decorated with the @tracked decorator - this is known as a tracked property, and indicates to Ember that the field will change in value over the lifetime of the component. You can learn more about tracked properties in the Autotracking In-Depth guide.

Next, we need to hook up the button to toggle that property. We'll do this with an action:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ButtonWithConfirmationComponent extends Component {
  @tracked isConfirming = false;

  @action
  launchConfirmDialog() {
    this.isConfirming = true;
  }
}
<button type="button" {{on "click" this.launchConfirmDialog}}>
  {{@text}}
</button>

{{#if this.isConfirming}}
  <div class="confirm-dialog">
    <button type="button" class="confirm-submit">
      OK
    </button>
    <button type="button" class="confirm-cancel">
      Cancel
    </button>
  </div>
{{/if}}

Now if we click the button, it will show the confirmation dialog - our first interactive component! We'll also want the modal to close when we click either of the modal buttons, so we can add a couple more actions to handle that:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ButtonWithConfirmationComponent extends Component {
  @tracked isConfirming = false;

  @action
  launchConfirmDialog() {
    this.isConfirming = true;
  }

  @action
  submitConfirm() {
    this.isConfirming = false;
  }

  @action
  cancelConfirm() {
    this.isConfirming = false;
  }
}
<button type="button" {{on "click" this.launchConfirmDialog}}>
  {{@text}}
</button>

{{#if this.isConfirming}}
  <div class="confirm-dialog">
    <button
      type="button"
      class="confirm-submit"
      {{on "click" this.submitConfirm}}
    >
      OK
    </button>
    <button
      type="submit"
      class="confirm-cancel"
      {{on "click" this.cancelConfirm}}
    >
      Cancel
    </button>
  </div>
{{/if}}

Now we can open and close the modal dialog at will! Next, we'll setup the component to send its own events when the user clicks the "OK" and "Cancel" buttons.

Exposing Actions as Public API

Let's create a parent component, the UserProfile component, where the user can delete their profile:

<ButtonWithConfirmation
  @text="Click OK to delete your account."
/>

First we'll define what we want to happen when the user clicks the button and then confirms. In the first case, we'll find the user's account and delete it.

We'll implement an action on the parent component called deleteAccount() that, when called, gets a hypothetical login service and calls the service's deleteUser() method. We'll go over services later on - for now, think of it as an API that manages the user's login and information.

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

export default class UserProfileComponent extends Component {
  @service login;

  @action
  deleteAccount() {
    this.login.deleteUser();
  }
}

Now we've implemented our action, but we have not told Ember when we want this action to be triggered. In order to trigger the action when the user clicks "OK" in the ButtonWithConfirmation component, we'll need to pass the action down to it as an argument:

<ButtonWithConfirmation
  @text="Click OK to delete your account."
  @onConfirm={{this.deleteAccount}}
/>

Next, in the child component we will implement the logic to confirm that the user wants to take the action they indicated by clicking the button:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ButtonWithConfirmationComponent extends Component {
  @tracked isConfirming = false;

  @action
  launchConfirmDialog() {
    this.isConfirming = true;
  }

  @action
  submitConfirm() {
    if (this.args.onConfirm) {
      this.args.onConfirm();
    }

    this.isConfirming = false;
  }

  @action
  cancelConfirm() {
    this.isConfirming = false;
  }
}

Now, when we click on the confirm button, the submitConfirm action will also call the deleteAccount action, which was passed down as an argument to the confirmation button component. In this way, the @onConfirm argument is like an event which our ButtonWithConfirmation component triggers.

Handling Action Completion

Often actions perform asynchronous tasks, such as making an ajax request to a server. Since actions are functions that can be passed in by a parent component, they are able to return values when called. The most common scenario is for an action to return a promise so that the component can handle the action's completion.

In our ButtonWithConfirmation component we want to leave the confirmation modal open until we know that the operation has completed successfully. This is accomplished by expecting a promise to be returned from onConfirm. Upon resolution of the promise, we set a property used to indicate the visibility of the confirmation modal. We can use an async function to handle that promise:

import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';

export default class ButtonWithConfirmationComponent extends Component {
  @tracked isConfirming = false;

  @action
  launchConfirmDialog() {
    this.isConfirming = true;
  }

  @action
  async submitConfirm() {
    if (this.args.onConfirm) {
      await this.args.onConfirm();
    }

    this.isConfirming = false;
  }

  @action
  cancelConfirm() {
    this.isConfirming = false;
  }
}

Passing Arguments

Sometimes the parent component invoking an action has some context needed for the action that the child component doesn't. Consider, for example, the case where the ButtonWithConfirmation component we've defined is used within SendMessage. The sendMessage action that we pass to the child component may expect a message type parameter to be provided as an argument:

import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class SendMessageComponent extends Component {
  @action
  async sendMessage(messageType) {
    // send message here and return a promise
  }
}

However, the ButtonWithConfirmation component invoking the action doesn't know or care what type of message it's collecting. In cases like this, the parent template can provide the required parameter when the action is passed to the child. For example, if we want to use the button to send a message of type "info":

<ButtonWithConfirmation
  @text="Click to send your message."
  @onConfirm={{fn this.sendMessage "info"}}
/>

Within ButtonWithConfirmation, the code in the submitConfirm action does not change. It will still invoke onConfirm without explicit arguments:

await this.args.onConfirm();

However the expression {{fn this.sendMessage "info"}} used in passing the action to the component creates a closure and partially applies the given parameter to the new function. So now when the action is invoked, that parameter will automatically be passed as its argument, effectively calling sendMessage("info"), despite the argument not appearing in the calling code.

So far in our example, the action we have passed to ButtonWithConfirmation is a function that accepts one argument, messageType. Suppose we want to extend this by allowing sendMessage to take a second argument, the actual text of the message the user is sending:

import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class SendMessageComponent extends Component {
  @action
  async sendMessage(messageType, messageText) {
    // send message here and return a promise
  }
}

We want to arrange for the action to be invoked from within ButtonWithConfirmation with both arguments. We've seen already that if we provide a messageType value to the fn helper when we insert ButtonWithConfirmation into its parent SendMessage template, that value will be passed to the sendMessage action as its first argument automatically when invoked as onConfirm. If we subsequently pass a single additional argument to onConfirm explicitly, that argument will be passed to sendMessage as its second argument (This ability to provide arguments to a function one at a time is known as partial application).

In our case, the explicit argument that we pass to onConfirm will be the required messageText. However, remember that internally our ButtonWithConfirmation component does not know or care that it is being used in a messaging application. Therefore within the component's JavaScript file, we will use a property confirmValue to represent that argument and pass it to onConfirm as shown here:

import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class ButtonWithConfirmationComponent extends Component {
  @action
  async submitConfirm() {
    if (this.args.onConfirm) {
      // call `onConfirm` with a second argument
      await this.args.onConfirm(this.confirmValue);
    }

    this.isConfirming = false;
  }

  //...
}

In order for confirmValue to take on the value of the message text, we'll bind the property to the value of a user input field that will appear when the button is clicked. To accomplish this, we'll first modify the component so that it can be used in block form and we will pass confirmValue as a block parameter within the confirm dialog element:

<button type="button" {{on "click" this.launchConfirmDialog}}>
  {{this.text}}
</button>

{{#if this.isConfirming}}
  <div class="confirm-dialog">
    {{yield this.confirmValue}}

    <button type="button"
      class="confirm-submit"
      {{on "click" this.submitConfirm}}
    >
      OK
    </button>
    <button type="button"
      class="confirm-cancel"
      {{on "click" this.cancelConfirm}}
    >
      Cancel
    </button>
  </div>
{{/if}}

With this modification, we can now use the component in SendMessage to wrap a text input element whose value attribute is set to confirmValue:

<ButtonWithConfirmation
  @text="Click to send your message."
  @onConfirm={{fn this.sendMessage "info"}}
as |confirmValue|>
  <Input @value={{confirmValue}} />
</ButtonWithConfirmation>

When the user enters their message into the input field, the message text will now be available to the component as confirmValue. Then, once they click the "OK" button, the submitConfirm action will be triggered, calling onConfirm with the provided confirmValue, thus invoking the sendMessage action in SendMessage with both the messageType and messageText arguments.

Invoking Actions Directly on Component Collaborators

Actions can be invoked on objects other than the component directly from the template. For example, in our SendMessage component we might include a service that processes the sendMessage logic.

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class SendMessageComponent extends Component {
  @service messaging;

  // component implementation
}

We can tell the action to invoke the sendMessage action directly on the messaging service.

<ButtonWithConfirmation
  @text="Click to send your message."
  @onConfirm={{fn this.messaging.sendMessage "info"}}
as |confirmValue|>
  <Input @value={{confirmValue}} />
</ButtonWithConfirmation>

The interesting part is that the action from the service just works, because it's auto-bound to that service.

import Service from '@ember/service';
import { action } from '@ember/object';

export default class Messaging extends Service {
  @action
  async sendMessage(messageType, text) {
    // handle message send and return a promise
  }
}

Destructuring Objects Passed as Action Arguments

A component will often not know what information a parent needs to process an action, and will just pass all the information it has. For example, our UserProfile component is going to notify its parent, SystemPreferencesEditor, that a user's account was deleted, and passes along with it the full user profile object.

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

export default class UserProfileComponent extends Component {
  @service login;

  @action
  async deleteAccount() {
    await this.login.deleteUser();

    this.args.didDelete(this.login.currentUserObj);
  }
}

All our SystemPreferencesEditor component really needs to process a user deletion is an account ID. For this case, the fn helper provides the value via partial application to allow a parent component to dig into the passed object to pull out only what it needs.

<UserProfile @didDelete={{fn this.userDeleted this.login.currentUser.id}} />

Now when the SystemPreferencesEditor handles the delete action, it receives only the user's account id string.

import Component from '@glimmer/component';
import { action } from '@ember/object';

export default class SystemPreferencesEditorComponent extends Component {
  @action
  userDeleted(idStr /* , native clickEvent */) {
    // respond to deletion
  }
}

Calling Actions Up Multiple Component Layers

When your components go multiple template layers deep, it is common to need to handle an action several layers up the tree.

Note about prop drilling / anti-patterns?

Parent components can pass actions to child components through templates alone without adding JavaScript code to those child components.

For example, say we want to move account deletion from the UserProfile component to its parent SystemPreferencesEditor.

First we would move the deleteUser action from user-profile.js to the parent system-preferences-editor.js.

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';

export default class SystemPreferencesEditorComponent extends Component {
  @service login;

  @action
  deleteUser(idStr) {
    return this.login.deleteUserAccount(idStr);
  }
}

Then our SystemPreferencesEditor template passes its local deleteUser action into the UserProfile as that component's deleteCurrentUser argument.

<UserProfile
  @deleteCurrentUser={{fn this.deleteUser this.login.currentUser.id}}
/>

The deleteUser action is prepended with this., since SystemPreferencesEditor is where the action is defined now. If the action was passed from a parent, then it might have looked like @deleteUser instead.

In our user-profile.hbs template we change our action to call deleteCurrentUser as passed above.

<ButtonWithConfirmation
  @text="Click OK to delete your account."
  @onConfirm={{@deleteCurrentUser}}
/>

Note that deleteCurrentUser is now prepended with @ as opposed to this. previously.

Now when you confirm deletion, the action goes straight to the SystemPreferencesEditor to be handled in its local context.

© 2020 Yehuda Katz, Tom Dale and Ember.js contributors
Licensed under the MIT License.
https://guides.emberjs.com/v3.25.0/in-depth-topics/patterns-for-actions