Skip to content

Reactive Synchronization

Reactive synchronization is the last piece of the reactive puzzle. Syncs 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:

  1. Sets up internal reactive state to represent the state of the external resource.
  2. Sets up a listener to state-change events.
  3. When the external state changes, updates the reactive state to reflect the change.
  4. Exposes a public API that allows consumers to access the current state.
  5. 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
ts
import * as reactive from "@starbeam/collections";
import { Sync } from "@starbeam/universal";
 
interface CurrentTitle {
title: string | null;
}
 
export function Title(): Sync<Readonly<CurrentTitle>> {
return Sync(({ on }) => {
const htmlTitle = 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.
const state: 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.
const observer = new MutationObserver(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.
return state;
 
// 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 * as reactive from "@starbeam/collections";
import { Sync } from "@starbeam/universal";
 
interface CurrentTitle {
title: string | null;
}
 
export function Title(): Sync<Readonly<CurrentTitle>> {
return Sync(({ on }) => {
const htmlTitle = 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.
const state: 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.
const observer = new MutationObserver(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.
return state;
 
// 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.

tsx
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
ts
import { on, tracked } from "@starbeam/universal";
 
export class Title {
// 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() {
const htmlTitle = document.querySelector("title");
 
if (htmlTitle) {
// on.sync allows us to define the synchronization
// behavior of this class.
on.sync(() => {
const observer = new MutationObserver(() => {
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.
get title(): string | null {
return this.#title;
}
}
ts
import { on, tracked } from "@starbeam/universal";
 
export class Title {
// 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() {
const htmlTitle = document.querySelector("title");
 
if (htmlTitle) {
// on.sync allows us to define the synchronization
// behavior of this class.
on.sync(() => {
const observer = new MutationObserver(() => {
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.
get title(): 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
ts
import { on, tracked } from "@starbeam/universal";
 
export class Title {
@tracked accessor #title: string | null = null;
readonly #htmlTitle: HTMLTitleElement | null = null;
 
constructor() {
const htmlTitle = document.querySelector("title");
 
if (htmlTitle) {
this.#htmlTitle = htmlTitle;
 
on.sync(() => {
const observer = new MutationObserver(() => {
this.#title = htmlTitle.nodeValue;
});
 
observer.observe(htmlTitle, { childList: true });
 
return () => {
observer.disconnect();
};
});
}
}
 
set title(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;
}
}
 
get title(): string | null {
return this.#title;
}
}
ts
import { on, tracked } from "@starbeam/universal";
 
export class Title {
@tracked accessor #title: string | null = null;
readonly #htmlTitle: HTMLTitleElement | null = null;
 
constructor() {
const htmlTitle = document.querySelector("title");
 
if (htmlTitle) {
this.#htmlTitle = htmlTitle;
 
on.sync(() => {
const observer = new MutationObserver(() => {
this.#title = htmlTitle.nodeValue;
});
 
observer.observe(htmlTitle, { childList: true });
 
return () => {
observer.disconnect();
};
});
}
}
 
set title(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;
}
}
 
get title(): string | null {
return this.#title;
}
}

Starbeam Resources Are JavaScript Resources