Skip to content
On this page

Custom Reactive Objects

You're not limited to using reactive objects and reactive arrays. You can create your own custom reactive objects.

A custom reactive object is a regular JavaScript object returned from a function. The function is called a reactive constructor.

What We're Building

Open on CodeSandbox

The Reactive Constructor

tsx
import { Cell } from "@starbeam/universal";
 
// `CounterData` is a reactive constructor
export function CounterData(label) {
const counter = Cell(0);
 
function increment() {
counter.current++;
}
 
function reset() {
counter.set(0);
}
 
return {
label,
 
get count() {
return counter.current;
},
 
increment,
reset,
};
}
tsx
import { Cell } from "@starbeam/universal";
 
// `CounterData` is a reactive constructor
export function CounterData(label) {
const counter = Cell(0);
 
function increment() {
counter.current++;
}
 
function reset() {
counter.set(0);
}
 
return {
label,
 
get count() {
return counter.current;
},
 
increment,
reset,
};
}
tsx
import { Cell } from "@starbeam/universal";
 
interface CounterData {
readonly label: string;
readonly count: number;
 
readonly increment: () => void;
readonly reset: () => void;
}
 
// `CounterData` is a reactive constructor
export function CounterData(label: string): CounterData {
const counter = Cell(0);
 
function increment() {
counter.current++;
}
 
function reset() {
counter.set(0);
}
 
return {
label,
 
get count() {
return counter.current;
},
 
increment,
reset,
};
}
tsx
import { Cell } from "@starbeam/universal";
 
interface CounterData {
readonly label: string;
readonly count: number;
 
readonly increment: () => void;
readonly reset: () => void;
}
 
// `CounterData` is a reactive constructor
export function CounterData(label: string): CounterData {
const counter = Cell(0);
 
function increment() {
counter.current++;
}
 
function reset() {
counter.set(0);
}
 
return {
label,
 
get count() {
return counter.current;
},
 
increment,
reset,
};
}

A reactive constructor is very similar to the Component function we've used in previous lessons.

The reactive constructor creates stable reactive values and functions, and returns an object that provides methods and accessors for interacting with the reactive values.

Using the Reactive Constructor in a Component

A reactive constructor is called in a setup function and used in a render function.

Here's how we use CounterData in our Counter component:

tsx
export function Counter() {
return Component(() => {
// In `Counter`'s setup function, we create two `CounterData`
// objects. The rest of the component uses the methods exposed
// by the `CounterData` objects to render the UI.
const counters = {
first: CounterData("first"),
second: CounterData("second"),
};
 
const total = () =>
counters.first.count + counters.second.count;
 
// The render function uses methods on the `CounterData` objects,
// which means that the render function will be called whenever
// the `count` property of either `CounterData` object changes.
return () => {
const { first, second } = counters;
 
return (
<>
<pre>
<span>{first.label}</span>
{" + "}
<span>{second.label}</span>
{" = "}
<span>total</span>
</pre>
<pre>
<span>{first.count}</span>
{" + "}
<span>{second.count}</span>
{" = "}
<span>{total()}</span>
</pre>
<h3 className="count1">{first.label}</h3>
<div className="buttons">
<button onClick={first.increment}>
increment
</button>
<button onClick={first.reset}>reset</button>
</div>
<h3 className="count2">{second.label}</h3>
<div className="buttons">
<button onClick={second.increment}>
increment
</button>
<button onClick={second.reset}>reset</button>
</div>
</>
);
};
});
}
tsx
export function Counter() {
return Component(() => {
// In `Counter`'s setup function, we create two `CounterData`
// objects. The rest of the component uses the methods exposed
// by the `CounterData` objects to render the UI.
const counters = {
first: CounterData("first"),
second: CounterData("second"),
};
 
const total = () =>
counters.first.count + counters.second.count;
 
// The render function uses methods on the `CounterData` objects,
// which means that the render function will be called whenever
// the `count` property of either `CounterData` object changes.
return () => {
const { first, second } = counters;
 
return (
<>
<pre>
<span>{first.label}</span>
{" + "}
<span>{second.label}</span>
{" = "}
<span>total</span>
</pre>
<pre>
<span>{first.count}</span>
{" + "}
<span>{second.count}</span>
{" = "}
<span>{total()}</span>
</pre>
<h3 className="count1">{first.label}</h3>
<div className="buttons">
<button onClick={first.increment}>
increment
</button>
<button onClick={first.reset}>reset</button>
</div>
<h3 className="count2">{second.label}</h3>
<div className="buttons">
<button onClick={second.increment}>
increment
</button>
<button onClick={second.reset}>reset</button>
</div>
</>
);
};
});
}
tsx
export function Counter(): JSX.Element {
return Component(() => {
// In `Counter`'s setup function, we create two `CounterData`
// objects. The rest of the component uses the methods exposed
// by the `CounterData` objects to render the UI.
const counters = {
first: CounterData("first"),
second: CounterData("second"),
};
 
const total = () =>
counters.first.count + counters.second.count;
 
// The render function uses methods on the `CounterData` objects,
// which means that the render function will be called whenever
// the `count` property of either `CounterData` object changes.
return () => {
const { first, second } = counters;
 
return (
<>
<pre>
<span>{first.label}</span>
{" + "}
<span>{second.label}</span>
{" = "}
<span>total</span>
</pre>
<pre>
<span>{first.count}</span>
{" + "}
<span>{second.count}</span>
{" = "}
<span>{total()}</span>
</pre>
<h3 className="count1">{first.label}</h3>
<div className="buttons">
<button onClick={first.increment}>
increment
</button>
<button onClick={first.reset}>reset</button>
</div>
<h3 className="count2">{second.label}</h3>
<div className="buttons">
<button onClick={second.increment}>
increment
</button>
<button onClick={second.reset}>reset</button>
</div>
</>
);
};
});
}
tsx
export function Counter(): JSX.Element {
return Component(() => {
// In `Counter`'s setup function, we create two `CounterData`
// objects. The rest of the component uses the methods exposed
// by the `CounterData` objects to render the UI.
const counters = {
first: CounterData("first"),
second: CounterData("second"),
};
 
const total = () =>
counters.first.count + counters.second.count;
 
// The render function uses methods on the `CounterData` objects,
// which means that the render function will be called whenever
// the `count` property of either `CounterData` object changes.
return () => {
const { first, second } = counters;
 
return (
<>
<pre>
<span>{first.label}</span>
{" + "}
<span>{second.label}</span>
{" = "}
<span>total</span>
</pre>
<pre>
<span>{first.count}</span>
{" + "}
<span>{second.count}</span>
{" = "}
<span>{total()}</span>
</pre>
<h3 className="count1">{first.label}</h3>
<div className="buttons">
<button onClick={first.increment}>
increment
</button>
<button onClick={first.reset}>reset</button>
</div>
<h3 className="count2">{second.label}</h3>
<div className="buttons">
<button onClick={second.increment}>
increment
</button>
<button onClick={second.reset}>reset</button>
</div>
</>
);
};
});
}

Using a Native Class as a Reactive Constructor

You can also build custom reactive objects using a native JavaScript class.

As a project, Starbeam supports both styles as first-class ways to create custom reactive objects. If you prefer native classes, go for it! On the other hand, if you dislike native classes, you can do everything using normal JavaScript functions. The choice is yours 😁.

Here's the same CounterData reactive constructor, but implemented as a native class using private fields:

tsx
export class CounterData {
#counter = Cell(0);
 
constructor(label) {
this.label = label;
}
 
increment = () => this.#counter.update((i) => i + 1);
 
reset = () => this.#counter.set(0);
 
get count() {
return this.#counter.current;
}
}
tsx
export class CounterData {
#counter = Cell(0);
 
constructor(label) {
this.label = label;
}
 
increment = () => this.#counter.update((i) => i + 1);
 
reset = () => this.#counter.set(0);
 
get count() {
return this.#counter.current;
}
}
tsx
export class CounterData {
readonly #counter = Cell(0);
readonly label: string;
 
constructor(label: string) {
this.label = label;
}
 
increment = () => this.#counter.update((i) => i + 1);
 
reset = () => this.#counter.set(0);
 
get count(): number {
return this.#counter.current;
}
}
tsx
export class CounterData {
readonly #counter = Cell(0);
readonly label: string;
 
constructor(label: string) {
this.label = label;
}
 
increment = () => this.#counter.update((i) => i + 1);
 
reset = () => this.#counter.set(0);
 
get count(): number {
return this.#counter.current;
}
}

The Future: JavaScript Decorators

In this lesson, we learned how to use standard JavaScript features to implement custom reactive objects.

At the moment, Starbeam has experimental support for using decorators to streamline the process of building reactive objects using classes.

tsx
export class CounterData {
// Stage 1 decorators force us to use public fields here, which
// makes `count` public (and mutable).
@reactive count = 0;
 
constructor(label) {
this.label = label;
}
 
increment = () => this.count++;
reset = () => (this.count = 0);
} // #endregion
tsx
export class CounterData {
// Stage 1 decorators force us to use public fields here, which
// makes `count` public (and mutable).
@reactive count = 0;
 
constructor(label) {
this.label = label;
}
 
increment = () => this.count++;
reset = () => (this.count = 0);
} // #endregion
tsx
export class CounterData {
// Stage 1 decorators force us to use public fields here, which
// makes `count` public (and mutable).
@reactive count = 0;
readonly label: string;
 
constructor(label: string) {
this.label = label;
}
 
increment = () => this.count++;
reset = () => (this.count = 0);
} // #endregion
tsx
export class CounterData {
// Stage 1 decorators force us to use public fields here, which
// makes `count` public (and mutable).
@reactive count = 0;
readonly label: string;
 
constructor(label: string) {
this.label = label;
}
 
increment = () => this.count++;
reset = () => (this.count = 0);
} // #endregion

That experimental support uses Stage 1 decorators, because that's what the JavaScript ecosystem supported when we built the feature. However, the JavaScript standards body has now approved decorators as a Stage 3 feature, and TypeScript support for Stage 3 decorators is coming soon.

js
js
export class CounterData {
// Stage 3 decorators support private fields, so we can turn
// `#counter` into a cell, but only expose a readonly getter
// as a public property.
@reactive @exposed accessor #counter = 0;
readonly label: string;
constructor(label: string) {
this.label = label;
}
increment = () => this.#counter++;
reset = () => (this.#counter = 0);
}
js
export class CounterData {
// Stage 3 decorators support private fields, so we can turn
// `#counter` into a cell, but only expose a readonly getter
// as a public property.
@reactive @exposed accessor #counter = 0;
readonly label: string;
constructor(label: string) {
this.label = label;
}
increment = () => this.#counter++;
reset = () => (this.#counter = 0);
}

Stage 3 decorators are more flexible (they support private fields, for example). These flexibility improvements eliminate the biggest caveats with the experimental decorator support, and we're excited to migrate our experimental decorator support to Stage 3 in the coming months.

Coming Soon to a Starbeam Near You 🚀

Since Stage 3 decorators are coming, but not yet fully supported by the JS ecosystem, we suggest writing custom reactive objects without decorators, as described in this lesson.

Once Stage 3 decorators have landed in TypeScript, we intend to make them a more important part of how we recommend building custom reactive objects using native classes.

Released under the MIT license