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
The Reactive Constructor
tsx
import {Cell } from "@starbeam/universal";// `CounterData` is a reactive constructorexport functionCounterData (label ) {constcounter =Cell (0);functionincrement () {counter .current ++;}functionreset () {counter .set (0);}return {label ,getcount () {returncounter .current ;},increment ,reset ,};}
tsx
import {Cell } from "@starbeam/universal";// `CounterData` is a reactive constructorexport functionCounterData (label ) {constcounter =Cell (0);functionincrement () {counter .current ++;}functionreset () {counter .set (0);}return {label ,getcount () {returncounter .current ;},increment ,reset ,};}
tsx
import {Cell } from "@starbeam/universal";interfaceCounterData {readonlylabel : string;readonlycount : number;readonlyincrement : () => void;readonlyreset : () => void;}// `CounterData` is a reactive constructorexport functionCounterData (label : string):CounterData {constcounter =Cell (0);functionincrement () {counter .current ++;}functionreset () {counter .set (0);}return {label ,getcount () {returncounter .current ;},increment ,reset ,};}
tsx
import {Cell } from "@starbeam/universal";interfaceCounterData {readonlylabel : string;readonlycount : number;readonlyincrement : () => void;readonlyreset : () => void;}// `CounterData` is a reactive constructorexport functionCounterData (label : string):CounterData {constcounter =Cell (0);functionincrement () {counter .current ++;}functionreset () {counter .set (0);}return {label ,getcount () {returncounter .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 functionCounter () {returnComponent (() => {// 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.constcounters = {first :CounterData ("first"),second :CounterData ("second"),};consttotal = () =>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 functionCounter () {returnComponent (() => {// 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.constcounters = {first :CounterData ("first"),second :CounterData ("second"),};consttotal = () =>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 functionCounter ():JSX .Element {returnComponent (() => {// 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.constcounters = {first :CounterData ("first"),second :CounterData ("second"),};consttotal = () =>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 functionCounter ():JSX .Element {returnComponent (() => {// 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.constcounters = {first :CounterData ("first"),second :CounterData ("second"),};consttotal = () =>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 classCounterData {#counter =Cell (0);constructor(label ) {this.label =label ;}increment = () => this.#counter.update ((i ) =>i + 1);reset = () => this.#counter.set (0);getcount () {return this.#counter.current ;}}
tsx
export classCounterData {#counter =Cell (0);constructor(label ) {this.label =label ;}increment = () => this.#counter.update ((i ) =>i + 1);reset = () => this.#counter.set (0);getcount () {return this.#counter.current ;}}
tsx
export classCounterData {readonly #counter =Cell (0);readonlylabel : string;constructor(label : string) {this.label =label ;}increment = () => this.#counter.update ((i ) =>i + 1);reset = () => this.#counter.set (0);getcount (): number {return this.#counter.current ;}}
tsx
export classCounterData {readonly #counter =Cell (0);readonlylabel : string;constructor(label : string) {this.label =label ;}increment = () => this.#counter.update ((i ) =>i + 1);reset = () => this.#counter.set (0);getcount (): 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 classCounterData {// 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 classCounterData {// 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 classCounterData {// Stage 1 decorators force us to use public fields here, which// makes `count` public (and mutable).@reactive count = 0;readonlylabel : string;constructor(label : string) {this.label =label ;}increment = () => this.count ++;reset = () => (this.count = 0);} // #endregion
tsx
export classCounterData {// Stage 1 decorators force us to use public fields here, which// makes `count` public (and mutable).@reactive count = 0;readonlylabel : 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
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.