The DevFest Nantes 2019 presentation of Building Complex Applications with Web Components is probably one of the most important to Lit developers; everyone references Justin Fagnani's presentation of how to do routing, lazy loading, and context management. The presentation includes a section on dependency injection: how an object can request its dependencies from other components higher up in the DOM tree.
One of the problems I have with the code as presented is that it fails to handle the case where the dependency supplier must perform some asynchronous work before sending the dependency to the requester. I'll show you how to extend the example to handle that, and what capabilities that extension gives you.
The code in the presentation works as follows:
Upon connectedCallback()
, the requesting component immediately sends an event with the appropriate
label indicating a request for a dependency. Included in the event's payload is either the this
of
the requester or an object contained by the requester.
An element tasked with being the dependency supplier higher up in the hierarchy is listening for that event; when it receives it, it unpacks the payload of the event and finds the object, into which it injects the data being requested. This data is immediately reflected in the requester.
In TypeScript, for this to work, the types all have to be in agreement; the requester must have an interface that includes the target key and the supplier must understand that the payload matches that interface. This doesn't change.
This choreography also depends on programmers understanding two things. First, that JavaScript is
pass-by-reference for objects and arrays, but not for primitives. (Not even string primitives.
Gigantic strings will be duplicated when passed from one variable to another, causing memory
wastage.) When you pass the this
or some sub-object of this
up in the event, that is a
reference to the requester itself or to the sub-object within the requester, so modifying it means
you are modifying the requester.
The second bit of esoterica is even more obscure: Events in the DOM are synchronous. When you send an event up the hierarchy, the receiver receives the event immediately and processes it, so by modifying the reference those changes are reflected immediately within the requester.
It's also important to know that modifying the sub-object won't actually trigger anything on the
requester, because modifying an object's internals doesn't modify the object's reference, and it
is the reference that Lit uses to know whether or not the requester needs to schedule
requestUpdate
.
The Original: Synchronous Dependency Requests
In the original example, we see this pattern on an object which is intended to retrieve the user's avatar without having to prop-drill it all the way from the top of the hierarchy, or supply it in a context. Which is a fair goal, although I do like Lit's context handling. The code block that follows is the code from the presentation. It's not wrong, but I'm going to show you two techniques for making this simple transaction much more powerful.
export class RequestAvatarEvent extends Event {
key: string;
avatar?: Avatar;
constructor(key: string) {
super("avatar-request", { composed: true, bubbles: true });
this.key = key;
}
}
class AvatarSupplier {
avatars: Map<string, Avatar>;
constructor() {
super();
this.addEventListener('avatar-request', (event: RequestAvatarEvent) => {
this.event.avatar = avatars.get(event.key);
});
}
}
class UserCard extends LitElement {
@state() avatar?: Avatar;
requestAvatar(key: string) {
const event = new RequestAvatarEvent(key);
this.dispatchEvent(event);
this.avatar = event.avatar;
}
}
The AvatarSupplier (really, it ought to be a mixin or controller on the application shell or some other high-level component, but go with it for now) listens for events of that type, and when it gets one it shoves an avatar into a named field in the event, which the requester of the event can immediately read and work with.
I have two problems with this, one legitimate and practical, the other... philosophical. The philosophical one is that I don't trust event handling to be synchronous. It's not something I've encountered before in JavaScript. The spec says that when an event is triggered all of the handlers will be called immediately, using the current stack context, in the order specified. Which is good to know!
The practical and legitimate one is simply this: what if the supplier needs to do some asynchronous work before it can answer the request?
Asynchronous Dependency Requests
For this use case, I find passing a dispatch handler to be much more useful. Instead of handing the dependency supplier an object, pass it a function with a closure:
type AvatarHandler = (avatar?: Avatar) => void;
export class RequestAvatarEvent extends Event {
key: string;
handler: AvatarHandler;
constructor(key: string, handler: AvatarHandler) {
super("avatar-request", { composed: true, bubbles: true });
this.handler = handler;
}
}
class AvatarSupplier {
avatarFetcher: (key: string) => Promise<Avatar>;
constructor() {
super();
this.addEventListener('avatar-request', (event: RequestAvatarEvent) => {
this.avatarFetcher(event.key).then((avatar?: Avatar) =>
this.event.handler(avatar);
)
});
}
}
class UserCard extends LitElement {
@state() avatar?: Avatar;
requestAvatar(key: string) {
this.dispatchEvent(new RequestAvatarEvent(key, (avatar) => {
this.avatar = avatar;
}));
}
}
In this model, the UserCard sends the request up to the Supplier. The Supplier performs
its own asynchronous function, then calls the remote event handler, which in this case just sets the
avatar on the requesting this
, triggering a state change and scheduling an update.
Aside from the likely network transaction (you are caching expensive network requests that happen multiple times to idempotent objects, right), all of this happens within milliseconds. Event handling within the browser is lightning fast and reliable these days.
The other important feature of this transanction model is that the Supplier doesn't need to know anything about the names of fields inside the Requester. It just calls the handler with the object it supplies. The Supplier is now decoupled from needing specific field identities permanently encoded; any object can request whatever it supplies and store it however that object prefers.
Pending Event Management
One common use case for this kind of interaction is to provide a pending handler. In this interaction, you have an object somewhere in your page (or island) that will need to be updated remotely, and while that's happening you don't want any interaction going on with the entire page. So you put up a spinner of some kind, right?
If you have multiple places where this sort of delay is possible, how do you manage the spinner
instance? Unsurprisingly, you do it with this paradigm, but instead of shipping up a handler, you
ship up a Promise.
export class SpinnerRequestEvent extends Event {
awaiter: Promise<unknown>
constructor(awaiter: Promise<unknown>) {
super("request-spinner", { composed: true, bubbles: true });
this.awaiter = awaiter;
}
}
class SpinnerOverlay {
constructor() {
super();
this.addEventListener('request-spinner', (event: SpinnerRequestEvent) => {
this.startSpinner();
event.awaiter
.then(() => this.stopSpinner())
.catch(() => this.stopSpinner());
});
}
}
class GridRetriever {
requestData() {
const fetching = fetch(this.dataSourceUrl);
this.dispatchEvent(new SpinnerRequestEvent(fetching));
fetching.then((data) => this.updateGrid(data));
}
}
The fetch()
API returns a promise, which we then dispatch up to the overlay, which immediately
starts the spinner and awaits the event to resolve or reject; either one stops the spinner and
allows the GridRetriever to display information about the transaction. The Retriever is also waiting
on the promise, so it "simultaneously" receives the response-or-rejection and can do useful things
with the data while the spinner is tearing down its visual effect. (It's a good thing a resolved
JavaScript Promise always returns its final value immediately as long as the promise is in scope.)
The unknown
there in the SpinnerRequestEvent is specific to this example: the spinner doesn't
care why it was asked to start up a spinner; it just cares that whatever promise that did so hasn't
finished yet, so merrily show the spinner until it resolves.
(Figuring out what to do if SpinnerOverlay gets two events at different times, and only wants to remove the spinner after the last of the two events completes, is left as an exercise; I haven't figured it out yet myself. I suspect it involves a queue.)
That's a Wrap
This post shows two different advanced techniques using events to handle complex interactions in a web component setting.
In the first, it shows how to use an event for Asynchronous Dependency Injection, making a request from a supplier high in the DOM hierarchy without contexts or having to mangle every component on the way down to carry the data deep into the hierarchy, even if the supplier has to perform asynchronous operations in order to retrieve that data. The tool for doing so is an Event with an enclosed function that can send a message back to the requesting object.
In the second, it shows how to use an event for Pending Event Management, affecting the behavior of objects high in the DOM hierarchy, allowing objects deep within to trigger effects and displays from the surrounding visual framework. It does this by sending an Event that triggers the start of the effect, and includes in the event a Promise that tells the high-level handler when to end the effect.
By understanding custom events (not, mind you,
CustomEvent
), you can pass up rich
stateful objects and message passing functions that allow you to decouple and distance your deep web
components from high-level context and effect handlers. The contract between the sender and the
receiver is narrow and specific, and neither has to know anything more about the other than the
specifics of the transaction represented by your event.
Remember that these are just examples. You can do so much more! There's no reason the
SpinnerRequestEvent couldn't be of type SpinnerRequestEvent<T = unknown>
, so you could pass in
promises that the receiver will interpret in some way; maybe differentiate between a PromiseResponse
and a PromiseReject, or change the display of the overlay depending on what came through the
promise. Promises can be chained, or collected (with .all()
and .allSettled()
), to create
efficient user interactions, and combined with events can be used to create powerful user
experiences.