Thursday, December 20, 2018

Lightning Web Components - Events, and listening to your children

Update - 22/12/2018

Thanks to an anonymous comments poster from inside Salesforce, I've come to learn that this post contained some inaccuracies - a typo in some code, a forgotten parameter to 'bind' that made an example more complex than it needed to be, and a lack of experience with Shadow DOMs that led to a failure in event propagation.

I'm massively grateful to the poster for pointing out my errors, and helping to learn a little more about the capabilities. I've left the original text intact and annotated where appropriate, to correct my errors - except the typo in the example - I've fixed that.

Another fantastic inclusion in Lightning Web Components is the completely reworked events model.

De-composing functionality and building smaller, and more generic building blocks has become much simpler and much more intuitive.

In the world of Lightning Components I never got on with events. The idea of adding a Salesforce configuration for an event, registering events on the dispatcher template, and then registering listeners on the receiving template seemed really cumbersome. And then added onto that was the differences in syntax between component and application events. They just felt really unnatural.

In Lightning Web Components all this has become significantly simpler, and much more in-keeping with the standard HTML / Javascript model.

We've already seen how we can use @api allow state to be passed into our components. Now we're talking about notifying our parents when events occur.

I could go into deep detail on how this is done, but the documentation on this area is spot on, and there's no need to repeat it - follow the guide in the docs and you can't go far wrong. It's particularly well written and introduces the concept brilliantly.

That said, there has to be something to say, right?

Well, yes, and before I go into some of the less obvious limitations, let's just present a simple example:

  • In the child component, we create and dispatch an event.
  • When you include the child component, specify the handler for the event

Something along the lines of:

Child component's Javascript

import { LightningElement, track } from 'lwc';

export default class ChildComponent extends LightningElement {

    @track value;

    // Called from the onchange handler on an input
    handleValueChanged( event ) {
        this.value = event.target.value;
        this.dispatchEvent( new CustomEvent( 'valuechanged', { detail: this.value } ) );
    }
}

Parent component's template

    <c-child-component onvaluechanged={handleOnValueChanged}>

Parent component's Javascript

import { LightningElement, track } from 'lwc';

export default class ParentComponent extends LightningElement {

    @track updatedValue;

    handleOnValueChanged( event ) {
        this.updatedValue = event.detail;
    }
}

OK. So how simple is that? No Salesforce configuration to create, nice simple syntax, event handlers defined in the template, exactly the same way you would if it was a standard HTML tag

Without wanting to repeat the documentation from Salesforce, it's worth calling out a few important points:

  • dispatchEvent and CustomEvent are standard Javascript.
  • When you include the child component, you specify the handler for the event in the template.
    • The event should not start with 'on', and the attribute you assign the handler to will have 'on' appended to the start.
    • The fact we can specify the handler as 'onvaluechanged' when we create the tag is LWC specific, and for very good reason (explained later). You cannot do this with standard Web Components.
  • We can pass data from the child component in the event, by passing an object as the second parameter.
    • Note that the data can only be in the 'detail' property. If you add data to any other property you may accidentally overwrite a standard property, and if you don't use another standard property it won't be visible in the event anyway - you put data into 'detail', and that's all you have. Live with it.
    • You can pass an object, but if you do you should construct it there and then. But you probably shouldn't.

OK, that's all well and good - but where are the limitations?

Well, the main one I've found was a real surprise to me - to the point that I'm worried that I've misunderstood something.

Update - 22/12/2018

I did misunderstand something, so the next few statements are wrong. This isn't a limitation of LWC, nor even a Salesforce specific behaviour - this was me not understanding the shadow DOM model, and missing a vital piece of documentation. I've left it here for completion's sake only.

In the standard Javascript events model - all events propagate to all levels.

For example, if I have the following HTML:

    <div id="grandparent" onchange="handleChange();">
        <div id="parent">
            <div id="child">
                <input onchange="handleChange();"/>
            </div>
        </div>
    </div>

When the value of the input changes, the onchange event is handled by both the onchange handler on the input and the 'grandparent' div. Events propagate through the whole DOM, unless a handler stops it by calling 'stopPropogation' against the event.

It's generally recognised that this is a good thing, and that events should not be stopped unless there's very good reason.

However, as far as I can see, this is not true when you cross boundaries between LWCs. (Update 22/12/2018 - it IS true, but different, and explained later).

For example, if I had the above example for a child component, and included it in a parent as such:

Parent component's template

    <c-child-component onvaluechanged={handleOnValueChanged}>

And then included that in the grandparent as such:

Grandparent component's template

    <c-parent-component onvaluechanged={handleOnValueChanged}>

Assuming that the parent component does not raise a 'valuechanged' event of its own, the 'onvaluechanged' handler on the grandparent component will never get called.

It seems that you can only handle a component's event in its parent's scope. (Update 22/12/2018 - Not true, you can push events through the boundary, and how is explained later)

Note: these are actually slightly different scenarios I'm explaining, but I think it's worthwhile in order to illustrate the point. Also, there is a 'bubbles' property that you can set on the CustomEvent when you create it, although I didn't see a change in behaviour when I did that.

As I've said, I'm surprised by this behaviour, so am happy to be told I'm wrong, and learn where my mistake is.

Update - 22/12/2018

And it turns out, I HAVE been told where my mistake was, and so the following section has now been added.

It turns out that this behaviour is the default for events, but that it can be overridden. The mechanism for that is detailed here, and again, this is standard Javascript behaviour as detailed here.

In essence, what we have to do is define the event as one that both 'bubbles' and is 'composed'. The former means that it will travel upwards through the DOM, a resulting effect being that multiple event sources can be handled by the same handler. The latter means that it will cross the boundary between the Shadow DOM (the component that raised it), and into the next (the component that included the one that raised it), and then keep going all the way to the document root.

Child component's Javascript becomes

import { LightningElement, track } from 'lwc';

export default class ChildComponent extends LightningElement {

    @track value;

    // Called from the onchange handler on an input
    handleValueChanged( event ) {
        this.value = event.target.value;
        this.dispatchEvent( new CustomEvent( 'valuechanged', { detail: this.value, bubbles: true, composed: true } ) );
    }
}

This is all very well explained in the documentation, and so you should read it carefully to understand it.

Bubbling and Composed Events are extremely powerful elements, and allow you to perform some complex event management and (for example) react in many different ways to the same single event being fired. However, with that power comes great responsibility. The more 'global' in nature your events are, the harder they are to debug and the more care you need to take in naming them. I think I'm with Salesforce on this one - decompose your components and keep your events as local as possible.

Adding an event handler via Javascript

So what of the 'on' behaviour? Why is this such a cool addition?

Well, that's best explained by illustrating what we would need to do if this wasn't available to us.

Let's go back to our child component

Child component's Javascript

import { LightningElement, track } from 'lwc';

export default class ChildComponent extends LightningElement {

    @track value;

    // Called from the onchange handler on an input
    handleValueChanged( event ) {
        this.value = event.target.value;
        this.dispatchEvent( new CustomEvent( 'valuechanged', { detail: this.value } ) );
    }
}

It dispatches a 'valuechanged' event that we can handle in a parent component.

We include the child component with a simple node:

Parent component's template

    <c-child-component></c-child-component>

Note we are no longer setting onvaluechanged because, in our hypothetical scenario, this is not possible.

Now, in order to handle the event we need to attach a handler to the component in our parent component's Javascript.

First we need to find it, so we set a property on the component that we can use to retrieve it. You may default to setting an 'id', but it turns out that Salesforce will adjust the ids on nodes, so we can't rely on that. Instead, we decide to set a class:

Parent component's template

    <c-child-component class="child"></c-child-component>

Now, the parent component's Javascript. We need to hook into one of the lifecycle callbacks in order to attach our handler

You can see the docs for those functions here.

From there we find:

  • We can't use the constructor, as the component hasn't been added to the DOM yet.
  • We can't use the connectedCallback, as the component's children haven't been rendered yet.
  • We can use the renderedCallback, but this gets called multiple times - whenever any reactive properties change, so we need to protect against multiple adds.

So, maybe we can do this:

Update - 22/12/2018

In the following section I missed an important parameter that could be passed into the 'bind' onto the event listener registration. This led to a slightly convoluted solution using fat arrows. I'm leaving that text here, as I think it's an interesting aside, although at the end I'll present a simpler solution.

    allocatedEventListeners = false;

    renderedCallback() {
        if ( ! this.allocatedEventListeners ) {
            this.template.querySelector('.child').addEventListener( this.handleOnValueChanged.bind() );
            this.allocatedEventListeners = true;
        }
    }

That is a bit clunky, but it looks like it should work. We 'bind' the 'handleOnValueChanged' function to the event listener.

Unfortunately, it doesn't. Because of a fundamental capability of Javascript - it appears that the event handler doesn’t have access to ‘this’. And if you’re not an experienced Javascript developer then that’s when things start to get a bit crazy (actually, even if you ARE an experienced Javascript developer, I suspect it STILL gets a little messed up).

Basically, 'this' isn’t guaranteed to be what you think it is. If you write code that behaves in a procedural way, then it will generally be the object in which the method is defined. But as soon as you add in callbacks, Promises and asynchronous behaviour, it isn't guaranteed to be.

'this' can be simply the context in which the function runs, rather than the object or class in which the function is defined. This is an incredibly powerful aspect of Javascript that is very difficult to get to grips with unless you’re used to seeing it.

In Lightning Components you can see the effect of this in code such as Apex callouts in helpers where you end up with:

    let self = this;

In our particular case, you could use an alternative - the fat arrow notation for defining functions.

 event => { this.handleOnValueChanged( event ) }

Which is *would* transpile to (or is synonymous with) this:

    function handleEvent(event) {
        var _this = this;
        ( function (event) { _this.handleOnValueChanged(event); });
    }

Look familiar?

The resulting code for adding the event handler could end up like this:

    allocatedEventListeners = false;

    renderedCallback() {
        if ( ! this.allocatedEventListeners ) {
            this.template.querySelector('.child')
                          .addEventListener( 'valuechanged',
                                            ( ( event ) => { this.handleOnValueChanged( event ) } ).bind() );
            this.allocatedEventListeners = true;
        }
    }

In the end, this would work. But no-one would suggest it was elegant. And in order to get it working we had to brush up against some advanced behaviour of 'this'. Now, I admit that people are going to have to learn how 'this' and its binding behaves in order to write reliable Lightning Web Components - but just to add an event handler?

Update - 22/12/2018

A simpler solution than a fat arrow, would be to use 'bind' for the purpose it was designed and pass in the context into the call. I.E. to do the following.

    allocatedEventListeners = false;

    renderedCallback() {
        if ( ! this.allocatedEventListeners ) {
            this.template.querySelector('.child').addEventListener( this.handleOnValueChanged.bind( this ) );
            this.allocatedEventListeners = true;
        }
    }

This has the same effect as the fat-arrow function, in that is ensures that when 'handleOnValueChanged' is executed, it is done so with the context of 'this' being that of the component's object.

However, even though this is slightly easier to read, I still do not recommend registering event listeners using Javascript. Use the template notation instead.

The reality is that we don't have to think about it - Salesforce have given us a very usable shorthand for it, and we should be extremely grateful for it!

3 comments:

Unknown said...

Hey Rob, very good analysis, we are appreciating each post, and we are trying to address some of your concerns, keep them coming.

As for this particular post, I think you're missing a big portion of the event system in web components, which is the retargeting logic of events. It is documented under the "Configure Event Propagation" section:

https://developer.salesforce.com/docs/component-library/documentation/lwc/lwc.events_propagation

This explains how events can cross the shadow boundaries, and how components listening for certain events will perceive the event.target, which is a fundamental part of the shadow DOM semantics.

Unknown said...

As for the last example, it should work just fine, you are just adding the bind() to the incorrect operand and you're missing the argument to the bind as well, you can do:

this.template.querySelector('.child').addEventListener( 'valuechanged', this.handleOnValueChanged.bind(this) ) );

We don't do anything special here, it is just regular DOM manipulation, and interaction.

Keep in mind that this is very odd way to add a listener, the template grammar is always a lot more efficient, and so we should always favor that one.

Rob Baillie said...

Thanks for your comments, it's fantastic of you to take the time to help me out. And yes - I can't quite believe that I missed the fact that you can pass the context into bind. That is, after all, a main reason for it's existence. Once you start using fat arrow functions, it's hard to give them up! (An edit will come)

I'll re-read the events documentation, and I'm sure to post again after some more experiments. As I wanted to get across in my post, I wasn't so sure I'd got the behaviour quite right, but I just couldn't get an event to bubble. I'm certain I'll come back to it - it looks like understanding how to use Events in LWS properly is going to be vital for building reusable and elegant components.

Again, thanks for your comments!