Reactive Synchronization
Reactive synchronization is the last piece of the reactive puzzle. Sync
s allow you to synchronize external state with Starbeam reactive state.
Sync
: Making External State Reactive
In practice, an API representing external state exposes an event-driven API: changes to the state are communicated as events.
You can use Sync
to synchronize that external state with the reactivity system. This makes it possible to keep rendered outputs up to date with external state, just as you would with reactive state.
To synchronize external state, a Sync
:
- Sets up internal reactive state to represent the state of the external resource.
- Sets up a listener to state-change events.
- When the external state changes, updates the reactive state to reflect the change.
- Exposes a public API that allows consumers to access the current state.
- When the resource is disposed, it disconnects the listener.
Example: Synchronizing the Current Page <title>
In this example, we'll synchronize the current page title (the current value of <title>
) with the reactivity system.
ts
import * asreactive from "@starbeam/collections";import {Sync } from "@starbeam/universal";interfaceCurrentTitle {title : string | null;}export functionTitle ():Sync <Readonly <CurrentTitle >> {returnSync (({on }) => {consthtmlTitle =document .querySelector ("title");// 1. Set up internal reactive state for the title. If the// <title> does not exist, the "current title" will always// be null.conststate :CurrentTitle =reactive .object ({title : null,});if (htmlTitle ) {on .sync (() => {// 2. Set up a listener to state-change events.// In this case, we are setting up a listener// that will fire when the title changes.constobserver = newMutationObserver (function () {// 3. When the external state (the title) changes, update// the reactive state to reflect the change.state .title =htmlTitle .nodeValue ;});observer .observe (htmlTitle , {childList : true });// 5. When the resource is disposed, disconnect the observer.return () => {observer .disconnect ();};});}// 4. Exposes a public API that allows consumers to access// the current state.returnstate ;// In this case, we're just exposing the// `{title: string | null}` directly, but we could expose any// object we want, and its getters or methods can read from// the state.});}
ts
import * asreactive from "@starbeam/collections";import {Sync } from "@starbeam/universal";interfaceCurrentTitle {title : string | null;}export functionTitle ():Sync <Readonly <CurrentTitle >> {returnSync (({on }) => {consthtmlTitle =document .querySelector ("title");// 1. Set up internal reactive state for the title. If the// <title> does not exist, the "current title" will always// be null.conststate :CurrentTitle =reactive .object ({title : null,});if (htmlTitle ) {on .sync (() => {// 2. Set up a listener to state-change events.// In this case, we are setting up a listener// that will fire when the title changes.constobserver = newMutationObserver (function () {// 3. When the external state (the title) changes, update// the reactive state to reflect the change.state .title =htmlTitle .nodeValue ;});observer .observe (htmlTitle , {childList : true });// 5. When the resource is disposed, disconnect the observer.return () => {observer .disconnect ();};});}// 4. Exposes a public API that allows consumers to access// the current state.returnstate ;// In this case, we're just exposing the// `{title: string | null}` directly, but we could expose any// object we want, and its getters or methods can read from// the state.});}
If we render the synchronized title, it will update whenever the document's title changes.
const title = sync(Title);
render(() => {
return <h1>{title}</h1>;
});
Implementing Title
as a Class
You can also implement Title
as a class. This pattern makes the most sense if you have multiple methods that interact with the internal reactive state, or if you're working within a framework that prefers classes.
ts
import {on ,tracked } from "@starbeam/universal";export classTitle {// We're using the "tracked" decorator with a private field,// which allows us to mutate `#title` directly without// exposing a mutable field to users of `Title`.@tracked accessor #title: string | null = null;constructor() {consthtmlTitle =document .querySelector ("title");if (htmlTitle ) {// on.sync allows us to define the synchronization// behavior of this class.on .sync (() => {constobserver = newMutationObserver (() => {this.#title =htmlTitle .nodeValue ;});observer .observe (htmlTitle , {childList : true });return () => {observer .disconnect ();};});}}// Whenever #title is synchronized, it will invalidate any// render that used the `title` getter.gettitle (): string | null {return this.#title;}}
ts
import {on ,tracked } from "@starbeam/universal";export classTitle {// We're using the "tracked" decorator with a private field,// which allows us to mutate `#title` directly without// exposing a mutable field to users of `Title`.@tracked accessor #title: string | null = null;constructor() {consthtmlTitle =document .querySelector ("title");if (htmlTitle ) {// on.sync allows us to define the synchronization// behavior of this class.on .sync (() => {constobserver = newMutationObserver (() => {this.#title =htmlTitle .nodeValue ;});observer .observe (htmlTitle , {childList : true });return () => {observer .disconnect ();};});}}// Whenever #title is synchronized, it will invalidate any// render that used the `title` getter.gettitle (): string | null {return this.#title;}}
For this example, the difference is largely aesthetic. You can easily refactor from one style to another based on your needs, without changing how consumers interact with the Sync
.
Refinement: Allowing Consumers to Change the Title
ts
import {on ,tracked } from "@starbeam/universal";export classTitle {@tracked accessor #title: string | null = null;readonly #htmlTitle:HTMLTitleElement | null = null;constructor() {consthtmlTitle =document .querySelector ("title");if (htmlTitle ) {this.#htmlTitle =htmlTitle ;on .sync (() => {constobserver = newMutationObserver (() => {this.#title =htmlTitle .nodeValue ;});observer .observe (htmlTitle , {childList : true });return () => {observer .disconnect ();};});}}settitle (value : string) {if (this.#htmlTitle) {// Since we're synchronizing with external state, treat// the title element as the source of truth and update it.// This will ultimately trigger the mutation observer and// update the reactive state and update any render that// used the `title` getter in our framework's rendering// lifecycle.this.#htmlTitle.nodeValue =value ;}}gettitle (): string | null {return this.#title;}}
ts
import {on ,tracked } from "@starbeam/universal";export classTitle {@tracked accessor #title: string | null = null;readonly #htmlTitle:HTMLTitleElement | null = null;constructor() {consthtmlTitle =document .querySelector ("title");if (htmlTitle ) {this.#htmlTitle =htmlTitle ;on .sync (() => {constobserver = newMutationObserver (() => {this.#title =htmlTitle .nodeValue ;});observer .observe (htmlTitle , {childList : true });return () => {observer .disconnect ();};});}}settitle (value : string) {if (this.#htmlTitle) {// Since we're synchronizing with external state, treat// the title element as the source of truth and update it.// This will ultimately trigger the mutation observer and// update the reactive state and update any render that// used the `title` getter in our framework's rendering// lifecycle.this.#htmlTitle.nodeValue =value ;}}gettitle (): string | null {return this.#title;}}