From action up to closure actions

Ember closure actions have been out for almost a year now.1 We've stopped bubbling actions and embraced closure actions 2 since last year. Today, I'll show an example in our project that we update(upgrade) from "action up" approach to Ember closure actions, and illustrate how clear and decoupled the code is afterward.

This is a chat room where we have nested components like so: a chat window contains a list of messages, and it also includes a message input for the user to write a message. The message input has a send button for the user to submit the message. We also need to disable the button while sending the message, and re-enable it when the message is sent successfully. Assuming we have the following structure:

index
    ↳ chat-messages
        ↳ message-input
            ↳  <button>

Let's talk about accomplishing this using the "action up" approach.

The message-input has a send button, such that when the button is clicked, a message would be sent, where the actual send action is performed in index. So, we need to bubble action from message-input to chat-messages, and finally index.

Furthermore, In order to disable/enable the send button, we pass a callback function as the second parameter in the action we bubble up from message-input to chat-messages and to index.

Here's the message-input template, it is disabled when pending is true.

<!-- message-input/template.hbs -->
<button type="button" disabled={{pending}} {{action 'submit'}}>
    <span>Send</span>
</button>

When the button is clicked, the action submit is triggered. We then call the onSend action with message and a callback function so that we know when to re-enable the button.

//message-input/component.js
actions: {
    submit() {
        set(this, 'pending', true);
        this.sendAction("onSend", get(this, 'message'), () => {
            set(this, 'message', "");
            set(this, 'pending', false);
        });
    }
}

In our chat-messages, we have to bubble the send action up to index:

<!-- chat-messages/template.hbs -->
{{message-input onSend='sendMessage'}}
//chat-messages/component.js
actions: {
    sendMessage(message, callback) {
        this.sendAction('onSendMessage', message, callback);
    }
}

Now in index, we catch the action and send the message, then trigger the callback function when success.

<!-- index.hbs -->
{{chat-messages messages=messages onSendMessage='sendMessage'}}
//index/controller.js
sendMessage: function (message, callback) {
    this.get('channel').push("new_message", {
        message: {
            content: message
        }
    }).receive("ok", () => {
        callback();
    });
}

This is alright when we have just one nested component. But what if we had more, each nested component needs to bubble the action up to its parent; moreover, we would have to pass both message and callback through all these middlemen, which means tons of repetitive work.

This is where closure actions really show off. With closure actions, instead of bubbling actions up, we pass actions down, and the action we pass down returns a value for us to use. These are two important factors:

  1. No need to bubble actions up through all nested components.
  2. You can have the function you pass into the action return a value. In our case, to determine whether to re-enable pending button.

Let's start from index. First we pass action sendMessage to chat-messages:

<!-- index.hbs -->
{{chat-messages messages=messages onSendMessage=(action "sendMessage")}}

Then, we pass it down to message-input:

<!-- chat-messages/template.hbs -->
{{message-input onSend=(action onSendMessage)}}

When the send button is clicked, our message-input triggers the action directly by calling this.attr.onSend. It uses the promise returns from the closure action to re-enable the send button.

//message-input/component.js
actions: {
    submit() {
        const promise = this.attrs.onSend(get(this, 'message'));

        set(this, ‘pending’, true);
        promise.then(() => {
            if (!this.isDestroyed) {
                set(this, 'message', "");
                set(this, 'pending', false);
            }
        });
    }
}

And our index controller returns a promise for message-input to use:

//index/controller.js
actions: {
    sendMessage: function(message) {
        return new Ember.RSVP.Promise( (resolve/*, reject*/) => {
            this.get('channel').push("new_message", {
                message: {
                    content: message
                }
            }).receive("ok", () => {
                resolve();
            });
        }
    }
}

Even when we have multiple nested component, we need only to pass actions down in template.hbs, and there's no code needed in component.js like before! And the index/controller.js only cares about sending the message and returning a promise; there's no need to pass in extra callback function anymore!

Less work, less debugging!

There are many other and advanced usage of closure actions, like curry and {{yield}} an action. I strongly suggest you to read the following articles if you have not yet done so:

Thanks for reading!

Loading comments...