Skip to main content

Custom directives

Directives are objects that customize how Lit renders expressions. Using a directive in your template is like calling a function:

However, instead of simply returning a value to render, a directive gets special access to the underlying DOM associated with its expression. And a directive instance is persisted across multiple renders so it can maintain state. A directive can even update the DOM asynchronously, outside of the main update cycle.

While Lit ships with a number of built-in directives like repeat() and cache(), users can author their own custom directives. To create a directive:

  • Implement the directive as a class that extends the Directive class.
  • Pass your class to the directive() factory to create a directive function that can be used in Lit template expressions.

When this template is evaluated, the directive function (hello()) returns a DirectiveResult object, which instructs Lit to create or update an instance of the directive class (HelloDirective). Lit then calls methods on the directive instance to run its update logic.

Some directives need to update the DOM asynchronously, outside of the normal update cycle. To create an async directive, extend the AsyncDirective base class instead of Directive. See Async directives for details.

The directive class has a few built-in lifecycle methods:

  • The class constructor, for one-time initialization.
  • render(), for declarative rendering.
  • update(), for imperative DOM access.

You must implement the render() callback for all directives. Implementing update() is optional. The default implementation of update() calls and returns the value from render().

Async directives, which can update the DOM outside of the normal update cycle, use some additional lifecycle callbacks. See Async directives for details.

When Lit encounters a DirectiveResult in an expression for the first time, it will construct an instance of the corresponding directive class (causing the directive's constructor and any class field initializers to run):

As long as the same directive function is used in the same expression each render, the previous instance is reused, thus the state of the instance persists between renders.

The constructor receives a single PartInfo object, which provides metadata about the expression the directive was used in. This can be useful for providing error checking in the cases where a directive is designed to be used only in specific types of expressions (see Limiting a directive to one expression type).

The render() method should return the value to render into the DOM. It can return any renderable value, including another DirectiveResult.

In addition to referring to state on the directive instance, the render() method can also accept arbitrary arguments passed in to the directive function:

The parameters defined for the render() method determine the signature of the directive function:

In more advanced use cases, your directive may need to access the underlying DOM and imperatively read from or mutate it. You can achieve this by overriding the update() callback.

The update() callback receives two arguments:

  • A Part object with an API for directly managing the DOM associated with the expression.
  • An array containing the render() arguments.

Your update() method should return something Lit can render, or the special value noChange if no re-rendering is required. The update() callback is quite flexible, but typical uses include:

  • Reading data from the DOM, and using it to generate a value to render.
  • Imperatively updating the DOM using the element or parentNode reference on the Part object. In this case, update() usually returns noChange, indicating that Lit doesn't need to take any further action to render the directive.

Parts

Each expression position has its own specific Part object:

  • ChildPart for expressions in HTML child position.
  • AttributePart for expressions in HTML attribute value position.
  • BooleanAttributePart for expressions in a boolean attribute value (name prefixed with ?).
  • EventPart for expressions in an event listener position (name prefixed with @).
  • PropertyPart for expressions in property value position (name prefixed with .).
  • ElementPart for expressions on the element tag.

In addition to the part-specific metadata contained in PartInfo, all Part types provide access to the DOM element associated with the expression (or parentNode, in the case of ChildPart), which may be directly accessed in update(). For example:

In addition, the directive-helpers.js module includes a number of helper functions which act on Part objects, and can be used to dynamically create, insert, and move parts within a directive's ChildPart.

Calling render() from update()

The default implementation of update() simply calls and returns the value from render(). If you override update() and still want to call render() to generate a value, you need to call render() explicitly.

The render() arguments are passed into update() as an array. You can pass the arguments to render() like this:

While the update() callback is more powerful than the render() callback, there is an important distinction: When using the @lit-labs/ssr package for server-side rendering (SSR), only the render() method is called on the server. To be compatible with SSR, directives should return values from render() and only use update() for logic that requires access to the DOM.

Sometimes a directive may have nothing new for Lit to render. You signal this by returning noChange from the update() or render() method. This is different from returning undefined, which causes Lit to clear the Part associated with the directive. Returning noChange leaves the previously rendered value in place.

There are several common reasons for returning noChange:

  • Based on the input values, there's nothing new to render.
  • The update() method updated the DOM imperatively.
  • In an async directive, a call to update() or render() may return noChange because there's nothing to render yet.

For example, a directive can keep track of the previous values passed in to it, and perform its own dirty checking to determine whether the directive's output needs to be updated. The update() or render() method can return noChange to signal that the directive's output doesn't need to be re-rendered.

Limiting a directive to one expression type

Permalink to “Limiting a directive to one expression type”

Some directives are only useful in one context, such as an attribute expression or a child expression. If placed in the wrong context, the directive should throw an appropriate error.

For example, the classMap directive validates that it is only used in an AttributePart and only for the class attribute`:

The previous example directives are synchronous: they return values synchronously from their render()/update() lifecycle callbacks, so their results are written to the DOM during the component's update() callback.

Sometimes, you want a directive to be able to update the DOM asynchronously—for example, if it depends on an asynchronous event like a network request.

To set a value asynchronously, a directive needs to extend the AsyncDirective base class, which provides a setValue() API.

Here's an example of a simple async directive that renders a Promise value:

Here, the rendered template shows "Waiting for promise to resolve," followed by the resolved value of the promise, whenever it resolves.

Async directives often need to subscribe to external resources. To prevent memory leaks async directives should unsubscribe or dispose of resources when the directive instance is no longer in use. For this purpose, AsyncDirective provides the following extra lifecycle callbacks and API:

  • disconnected(): Called when a directive is no longer in use. Directive instances are disconnected in three cases:

    • When the DOM tree the directive is contained in is removed from the DOM
    • When the directive's host element is disconnected
    • When the expression that produced the directive no longer resolves to the same directive.

    After a directive receives a disconnected callback, it should release all resources it may have subscribed to during update or render to prevent memory leaks.

  • reconnected(): Called when a previously disconnected directive is being returned to use. Because DOM subtrees can be temporarily disconnected and then reconnected again later, a disconnected directive may need to react to being reconnected. Examples of this include when DOM is removed and cached for later use, or when a host element is moved causing a disconnection and reconnection. The reconnected() callback should always be implemented alongside disconnected(), in order to restore a disconnected directive back to its working state.

  • isConnected: Reflects the current connection state of the directive.

Note that it is possible for an AsyncDirective to continue receiving updates while it is disconnected if its containing tree is re-rendered. Because of this, update and/or render should always check the this.isConnected flag before subscribing to any long-held resources to prevent memory leaks.

Below is an example of a directive that subscribes to an Observable and handles disconnection and reconnection appropriately: