Reactive State with Signals in Lit

Yesterday the Lit project released the new @lit-labs/signals package that I've been working for a few months. @lit-labs/signals integrates the Signals TC39 Proposal with Lit πŸŽ‰ so that elements that use signals will automatically update when they change.

I'm really excited about this one! Partly because it's the first big new feature I've landed in Lit since leaving Google, but mostly because I think standardized signals can be a game-changer for web components, Lit, and all kinds of state management libraries.

So, what’s the big deal?

Lit Reactivity Overview

Well, first let's take a look at the current state of reactivity in Lit.

Lit has one main reactive environment - the ReactiveElement base class's "reactive update lifecycle". This is an asynchronously enqueued set of steps that perform the update of an element, including rendering in LitElement's case.

A reactive update can be triggered in any number of ways, but the main way is via reactive properties: class instance properties that request an update when they change.

This is a simple and quite effective system that composes quite well.

When using Lit elements in declarative template systems, a parent element setting properties on a child element will cause that child to enqueue an update microtask. Setting several properties, which is quite common, will enqueue just one task. Deep "property drilling" will cause a cascade of async microtasks that update elements in tree-order, each in their own batch.

Change detection in the reactive property setters means that elements will only enqueue an update if their data actually changed. This means that only the part of a Lit element subtree that needs re-rendering will even try to re-render. Change detection in the template bindings means that only DOM that's bound to changed data will update.

It's a very efficient and pretty simple system that limits the effect scope of data changes and performs minimal DOM updates, all for a small amount of code. On top of that, it's not centrally coordinated and it's interoperable. Any other web component that responds to property changes by enqueuing microtasks will benefit from the same, emergent, in-order, batched, naturally tree-pruning behavior!

Lit dataflow demo

Here's a demo that shows how components only update when their own data changes, and how that makes data flow down the tree.

Each node has a baseValue and rightValue and displays their sum, but the rightValue is only bound to the right child. The elements flash when they re-render, and delay their updates by 100ms.

You can see that changing baseValue updates the whole tree because that value flows to every node, but changing rightValue only updates the right-most branch of the tree.

Edit the files in this demo

Great, so what's missing?

Lit's system is awesome (I know... I'm biased), but it purposefully doesn't handle a lot of data management cases, and it's shallow.

Lit, in the spirit of interoperable web components, is inherently local. Elements only control their own API surface. They don't dictate what kind of data you use, they don't patch other objects or globals. They don't interject Proxies into your data objects.

This means that Lit's reactive properties have a sometimes limited view of state changes - they only see changes on the elements themselves. This is similar to React which can only "see" top-level changes to props and state from useState(). Observing deep changes is out of scope.

For developers, there are two main implications:

  1. Elements may not update automatically in response to deep property changes.
  2. Lit may do more work than strictly necessary to render a template because it assumes any state could have changed. (Though no-op updates are very fast)

So how do elements respond to data changes deep within objects or collections?

The answer is it's left up to developers.

And this isn't a bad answer in my opinion. There are a lot of ways to try to get deep observability of data, with few clear winners, and a lot of downsides to various approaches.

You have systems that lean on immutability, like Redux, and actually work pretty great with Lit's reactivity out of the box. Old proposals like Object.observe(). Systems that use Proxies to wrap data and detect mutations. Systems that patch a bunch of globals like Array, Map, Set., etc. to detect mutations. Patterns based on EventTarget, or Observables. Systems that let you build complex graphs of observable objects that work like generic versions of Lit's reactivity.

What's nice about Lit (via the ReactiveElement base class) is that all of these observation systems can be plugged into Lit in the same way: use their custom observation API, and call .requestUpdate() when something changes to provoke a Lit updates. There are Lit adapters for MobX, Redux, Preact Signals, RxJS, XState, etc., etc.

And of course, element authors can just call this.requestUpdate() if an element knows some data has changed, like in an event handler that performs a mutation.

Lit + Signals

Before rambling on more, let's check out the new @lit-labs/signals package.

The main thing is exports is the SignalWatcher mixin. Using it is incredibly simple: Apply it to your element, use some signals, and your element will update when the signals change. That's it!

Here's a very basic demo of a shared signal that can be read and written to by multiple elements:

This is just the simplest demo. You can get much more involved: put signals on instance fields of elements, use computed signals, build complex objects out of signals, share signals with the WCCG Context protocol, etc...

Cool, but why am I so excited about signals? πŸ€”

Fundamentally, the new signals integration is just more of the same: a helper that wraps the Lit update lifecycle to watch it and call this.requestUpdate() when it sees that signals have changed. There are some subtle niceties around skipping full re-renders for pinpoint DOM updates and such, but the approach is the same as with, say, MobX.

What's different with the TC39 Signals proposal is that it advocates for adding a reactive primitive directly to JavaScript.

This means that we won't have to bring along a library to use signals, but three things are even more impactful, in my opinion:

  1. Any library based on the JavaScript standard signals API will be interoperable.
  2. JavaScript will have a universal reactive primitive that can be pervasively used by libraries and relied on by other parts of the web platform, like the DOM.
  3. Other web standards can use signals

Interoperable signals

A future MobX based on standard signals will produce objects that are deeply observable by any library or framework that supports watching standard signals. No special MobX integration needed. Signal-backed collections, like those in the package signal-utils, can be mixed and matched from different libraries and seamlessly used in components, effects, etc.

The interoperability of standard signals is perfectly aligned with web components and will increase web components interoperability as well. Now multiple web components built with multiple libraries, or none at all, can share the same observable data and react to it, without having to use the same non-standard library.

Universal reactive primitive

As a universal reactive primitive, more libraries will choose to make signals part of their APIs. A remote data-syncing library might make all of its data object be backed by signals, so they then just plug into this ecosystem of observers.

I also think there's huge potential for using signal-based constructs to build very rich, observable, headless data models that are easily usable from any UI layer.

Many developers currently use utilities on the UI side of an app for dealing with async data, for instance. Think things like TanStack Query or Lit's Task helper. These are extremely useful, but I think even better patterns emerge when your data model itself can handle fetching more data, caching, metadata, and synchronization. You can make very nice APIs that abstract away details that are unimportant for components (usually the how) and focus on what is important (the what), and the data can be portably used in any signal-aware environment.

Example: AsyncComputed

In my own code I've been moving away from using Lit's Task, which is a reactive controller that's tied to a single element, and towards using the AsyncComputed utility from signal-utils (a utility I contributed).

AsyncComputed lets you construct signals based on async computations. It's actually very similar to Lit's Task, but instead of hooking Lit's reactive lifecycle, it reads from signals and publishes its state as various signals.

This is a port of the Lit Task example to use AsyncComputed.

The interesting thing here is that the async work was moved into a UI-layer agnostic data model object. All the component has to do is read and write to the object. As data is fetched, the signals of the AsyncComputed that hold the result are updated, and the components that use it are automatically updated as well.

I find this pattern especially useful for applications that navigate large object graphs with lazy loaded edges. I also have a wrapper on AsyncComputed for lazy-loaded collections that helps with metadata like collection size and pagination. Using those collections from the UI layer is super easy.

A reactive type for web standards

One of the biggest changes that signals could bring, and only if they're standardized, is integration into other web standards like the DOM.

Imagine being able to assign a signal to an attribute or some text content and have the DOM automatically update when the signal changes!

const userName = new Signal.State('amy123');
const userNameSpan = document.querySelector('#user-name`);
// An imagined new API, but bear with me...
userNameSpan.setTextContent(userName);

// Later...
userName.set('joe456');
// And the <span> updates to <span>joe456</span>

I'm not sure about this setTextContext() API I just dreamed up above, but I do think we have a potential lead on this type of reactivity with the DOM Parts proposal which aims to add markers and objects that directly represent dynamic segments of DOM - perfect for signals integration.

<template parseparts>
  <p>Current user: {{}}</p>
</template>
const {node, parts} = template.content.cloneWithParts();
const userPart = parts[0];
userPart.setValue(userName);

// Later...
userName.set('joe456');
// And the <p> updates to <p>Current user: joe456</p>

If the DOM ever gets a declarative JavaScript template API, like I'm proposing in WICG/webcomponents#1069, we could do this with great ergonomics:

const {html} = HTMLElement;

const userName = new Signal.State('amy123');
document.body.render(html`<p>Current user: ${userName}</p>`);

// Later...
userName.set('joe456');
// And the <p> updates to <p>Current user: joe456</p>

There's a huge potential for built-in reactivity in the DOM, that could be unlocked by standard signals.

Looking forward with @lit-labs/signals

If you can't tell, I'm pretty thrilled by everything related to standardized signals right now: the TC39 signals proposal, our integration with Lit, and the seemingly endless possibilities ahead of us.

Like all standards work, it's going to take a little while. So we have some time to shore up how things work on the Lit end.

A few things I'd like to see added soon:

  • Support for auto-updating templates outside of LitElement and ReactiveElement: A lot of use cases are satisfied by simple templates, without the need for full-blown components. We'll need a scheduler interface in lit-html to make this work.
  • Signal aware repeat() directive: Signal-oriented frameworks have optimizations built into their loop constructs that we can copy over to Lit.
  • Signal-backed @property(): We can replace Lit's reactive property storage with signals and make integration into signals patterns even more powerful. Combined with effects would also address requests for property observers in Lit. Instead of a special Lit feature, you just read LitElement properties in a regular effect.
  • Batched effects in the standard signals API: I've made a feature request to the signals proposal to allow for batching of signal updates, with an immediate flush of batched effects. This would allow us to move to synchronous rendering in Lit, by using signal-backed @property()s and rendering in a batched effect, retaining our efficient DOM batching and tree-order rendering.
  • A tree-aware DOM scheduler API: Shared signals can introduce out-of-tree-order rendering without a central scheduler, but web components don't share a common scheduler, outside of built-ins like the microtask and task queues or animation frames, adn those aren't tree aware. I want to propose a tree-aware task scheduler for the DOM so that all web components, frameworks, and general DOM updating code can ensure that updates run in batched, top-down order.

On the Lit team, we'll be working hard on our libraries and standards proposals to help bring about our glorious, interoperable, reactive future 😎

In the meantime, please try out the new @lit-labs/signals package, signal-utils, and build your own projects with signals and let us know how things work, or don't, for you!

Find the Lit team and community on Discord or GitHub Discussions.

Links: