Using Javascript Promises to chain asynchronous actions in Salesforce Lightning

by Aidan Harding - May 19, 2017
Using Javascript Promises to chain asynchronous actions in Salesforce Lightning

Even the title of this article might make your head spin. But, if you develop Lightning Components, try to stick with it: it addresses a tricky use-case when coding Lightning Components and it is actually very neat when you get to the end.

Throughout this article, we’ll use the example of a very simple Lightning Component which displays a string. The string starts as just “a” and we use calls to the Apex controller to add more “a”s. The component definition is very simple:

<aura:component controller="PromisesDemoController" implements="flexipage:availableForAllPageTypes" access="global">
    <aura:attribute name="message" type="String" default="a" description="The string we're appending to"/>
    <aura:attribute name="successPercentage" type="Decimal" default="0.9" description="The chance that the apex code will succeed. 0 is definite fail, 1 is definite success" />
    <aura:attribute name="usePromises" type="Boolean" default="true" description="Flag to switch between promise/non-promise version"/>

    <aura:handler name="init" value="{!this}" action="{!c.doInit}"/>

    <div>
      Message value: <ui:outputText value="{!v.message}" />
    </div>
</aura:component>

And so is the Apex controller:
public class PromisesDemoController {

  @AuraEnabled
  public static String append(String s, Decimal successPercentage) {
    if(Math.random() > successPercentage) {
      return '' + 1/0;
    } else {
      return s + 'a';
    }
  }
}

The successPercentage parameter allows us to see what happens if an error occurs in the Apex, and how that can be handled using Promises.

Whenever we make a call to the server in a Lightning component, that call is made asynchronously and we receive the results via a callback.

For example,

({
    appendViaApex : function(component) {
        var appendAction = component.get('c.append');
        
        appendAction.setParams({
            's': component.get('v.message'),
            'successPercentage': component.get('v.successPercentage'),
        });

        appendAction.setCallback(this, function(response) {
            var state = response.getState();
            if (state === 'SUCCESS') {
                component.set('v.message', response.getReturnValue());
            }
        });
        $A.enqueueAction(appendAction);
    }
})

$A.enqueueAction(appendAction) tells Lightning to make the server-side call to append() at some later time. Once the framework has a result from the server, it calls the callback function so that you can do something with that result. In this case, updating the message attribute on our component.

This is fine until you need to start chaining calls to the server e.g. we want to append more than one “a” to the string. You could just extend what we have already, and make another call in the callback:

({
    appendViaApex : function(component) {
        var appendAction = component.get('c.append');
        
        appendAction.setParams({
            's': component.get('v.message'),
            'successPercentage': component.get('v.successPercentage'),
        });

        appendAction.setCallback(this, function(response) {
            var state = response.getState();
            if (state === 'SUCCESS') {
                component.set('v.message', response.getReturnValue());
                var appendAction2 = component.get('c.append');
                
                appendAction2.setParams({
                    's': component.get('v.message'),
                    'successPercentage': component.get('v.successPercentage'),
                });
                
                appendAction2.setCallback(this, function(response) {
                    var state = response.getState();
                    if (state === 'SUCCESS') {
                        component.set('v.message', response.getReturnValue());
                    }
                });

                $A.enqueueAction(appendAction2);
            }
        });
        $A.enqueueAction(appendAction);
    }
})

This is not going to scale well. We’re entering both callback hell and the pyramid of doom. Every further action that we need to chain is going to add another layer to this pyramid, and I haven’t even included error handlers here.

Promises are a way to avoid all of this. A great primer on Promises is available here https://developers.google.com/web/fundamentals/getting-started/primers/promises. You may have to read it a few times before it all syncs (ha!) in.

One of the aims of Promises is to be able to write asynchronous Javascript with callbacks as if it is synchronous. It does require you to really understand some things about Javascript that are beyond the scope of this article. If you’re not already familiar with Promises, I’d urge you to read that Google article until you are.

In the context of Salesforce Lightning, we know that Promises are supported (with a polyfill, so we don’t have to worry about browser support). Unfortunately, the example on that page is not very illuminating (at least, not very illuminating for me). So, let’s go back to appending “a”s and look at the controller of our component:

({
    doInit : function(component, event, helper) {
        if(component.get('v.usePromises')) {
            helper.helperFunctionAsPromise(component, helper.appendViaApex)
                .then($A.getCallback(function() {
                    return helper.helperFunctionAsPromise(component, helper.appendViaApex)
                }))
                .then($A.getCallback(function() {
                    return helper.helperFunctionAsPromise(component, helper.appendViaApex)
                }))
                .then($A.getCallback(function() {
                    return helper.helperFunctionAsPromise(component, helper.appendViaApex)
                }))
                .then($A.getCallback(function() {
                    console.log('Done, no errors');
                }))
                .catch($A.getCallback(function(err) {
                    var toastEvent = $A.get("e.force:showToast");
                    
                    toastEvent.setParams({
                        title: 'Error',
                        type: 'error',
                        message: err.message
                    });

                    toastEvent.fire();
                }))
                .then($A.getCallback(function() {
                    console.log('A bit like finally');
                }));
                    
        } else {
            helper.appendViaApex(component);
        }
    }
})

In this controller we have two branches: One for the Promises version, one for the non-Promises version (which only appends a single “a”). The non-promise branch shows that, even though we’ve extended a normal pattern for calling the server to make it work with Promises, we can still call it in the normal way. To avoid pyramids, and hells, we only call this version once.

On the Promise branch, the sequence is very clear: We append, then we append some more, then we append some more, then we append some more, then we say we’re done, (if anything up to there failed, we show a toast), and then (error or not) we say we’re finally done. Each of those calls to append is still using the appendViaApex() function, but it’s being passed through some magic in the helperFunctionAsPromise() function to make it work as a Promise.

Finally, there’s the helper:

({
    appendViaApex : function(component, resolve, reject) {
        var appendAction = component.get('c.append');
        
        appendAction.setParams({
            's': component.get('v.message'),
            'successPercentage': component.get('v.successPercentage'),
        });

        appendAction.setCallback(this, function(response) {
            var state = response.getState();
            if (state === 'SUCCESS') {
                component.set('v.message', response.getReturnValue());
                if(resolve) {
                    console.log('resolving appendViaApex');
                    resolve('appendViaApexPromise succeeded');
                }
            } else {
                if(reject) {
                    console.log('rejecting appendViaApexPromise');
                    reject(Error(response.getError()[0].message));
                }
            }
        });
        console.log('Queueing appendAction');
        $A.enqueueAction(appendAction);

    },
    helperFunctionAsPromise : function(component, helperFunction) {
        return new Promise($A.getCallback(function(resolve, reject) {
            helperFunction(component, resolve, reject);
        }));
    }
})

First, lets look at the changes to appendViaApex(). Now, it can take two extra arguments: resolve and reject. These will form part of the Promise. Javascript being what it is, they are optional arguments. This means that appendViaApex() can still be called in its original form without causing any trouble. When those function are passed in, though, we do the normal Promises thing of calling resolve() in the callback for success and reject() in the callback for error.

Now, look at helperFunctionAsPromise(). This simply wraps up any call to a helper function in a Promise. All you to need to do it make sure that the helper function you use calls resolve and reject in the right places. It also has the extra Lightning-specific detail of using $A.getCallback() to make sure that the callback is run properly within the Lightning framework (see Modifying Components Outside the Framework Lifecycle).

And that’s it. The very short take-away is that you can adopt Promises by just copying + pasting helperFunctionAsPromise() into your helper, and modifying your existing helper functions to use resolve()/reject() where appropriate. Then, you can easily chain their actions as in the controller here, and you can write a single error handler for the whole chain of calls. Tidy!

Updated 8/12/2017 Added $A.getCallback() wrappers around the functions inside the then() clauses of PromisesDemoController.js. This is required to make sure that the component reference inside your helper function is alive. Missing it out can sometimes work, but could cause unexpected failures in certain circumstances.

Related Content


Get In Touch

Whatever the size and sector of your business, we can help you to succeed throughout the customer journey, designing, creating and looking after the right CRM solution for your organisation