Skip to content

React

React is the output boundary for a Starbeam model. Your state can stay ordinary JavaScript: classes, getters, methods, maps, and resources. useReactive() is where React subscribes to the values your render reads.

Install the React adapter with the framework-neutral Starbeam packages you will use for root state and resources:

Terminal window
pnpm add @starbeam/react @starbeam/universal @starbeam/collections

Start by marking the storage that changes. The rest of the model can be normal domain code.

import { reactive } from "@starbeam/collections";
interface LineItem {
readonly id: string;
readonly name: string;
readonly priceCents: number;
readonly quantity: number;
}
interface ProductInput {
readonly name: string;
readonly priceCents: number;
}
export class Cart {
#items = reactive.Map<string, LineItem>();
#nextItemId = 1;
get items(): readonly LineItem[] {
return [...this.#items.values()];
}
get itemCount(): number {
return this.items.reduce((total, item) => total + item.quantity, 0);
}
get totalCents(): number {
return this.items.reduce(
(sum, item) => sum + item.priceCents * item.quantity,
0,
);
}
add(product: ProductInput): void {
const id = `item-${this.#nextItemId++}`;
this.#items.set(id, {
id,
name: product.name,
priceCents: product.priceCents,
quantity: 1,
});
}
}

#items is the reactive root state. itemCount, totalCents, and add() are ordinary JavaScript above it.

Use useReactive() at the React output boundary. The callback can return the React element for this component. Starbeam tracks the reactive reads that happen while the callback runs.

import { useReactive } from "@starbeam/react";
import type { Cart } from "./cart.js";
interface CartSummaryProps {
readonly cart: Cart;
}
export function CartSummary({ cart }: CartSummaryProps): React.ReactElement {
return useReactive(
() => (
<section>
<p>{cart.itemCount} items</p>
<p>${(cart.totalCents / 100).toFixed(2)}</p>
<button
type="button"
onClick={() => cart.add({ name: "Tea", priceCents: 500 })}
>
Add tea
</button>
</section>
),
[cart],
);
}

Pass the second argument when the callback closes over React values that can be replaced on a later render. In this example, cart is a prop, so [cart] tells Starbeam to rebuild the reactive read if React gives the component a different cart.

You do not list the Starbeam data the callback reads. cart.itemCount and cart.totalCents are tracked automatically when the callback runs. If the callback only reads stable module state or Starbeam state, omit the second argument.

Keep React hooks at the top level of the component, outside the useReactive() callback.

Use a Resource when state needs setup, sync, or cleanup. useResource() gives the resource a React component lifetime, and useReactive() reads the resource value for rendering.

import { useReactive, useResource } from "@starbeam/react";
import { reactive } from "@starbeam/collections";
import { Resource } from "@starbeam/universal";
const Clock = Resource(({ on }) => {
const clock = reactive.object({ now: new Date() });
on.sync(() => {
clock.now = new Date();
const timer = setInterval(() => {
clock.now = new Date();
}, 1000);
return () => clearInterval(timer);
});
return clock;
});
export function ClockLabel(): React.ReactElement {
const clock = useResource(Clock);
const now = useReactive(() => clock.now);
return <time>{now.toLocaleTimeString()}</time>;
}

The resource returns a domain-shaped value. The component reads that value at the same output boundary as the cart example. React owns the timing: sync runs after React commits, and cleanup/finalize run when React cleans up the component.

Use useElementResource() when a resource needs a DOM element from React. It returns a callback ref plus a pending or attached result. See Element resources and DOM attachment for the framework-neutral concept.

import { useElementResource, useReactive } from "@starbeam/react";
import { reactive } from "@starbeam/collections";
import { Resource } from "@starbeam/universal";
function ElementSize(element: HTMLElement) {
return Resource(({ on }) => {
const size = reactive.object({ width: 0, height: 0 });
on.sync(() => {
const observer = new ResizeObserver(([entry]) => {
if (!entry) return;
size.width = entry.contentRect.width;
size.height = entry.contentRect.height;
});
observer.observe(element);
return () => observer.disconnect();
});
return size;
});
}
export function MeasuredPanel(): React.ReactElement {
const size = useElementResource((element: HTMLElement) =>
ElementSize(element),
);
const label = useReactive(() => {
if (size.status === "pending") {
return "Measuring…";
}
return `${size.current.width} × ${size.current.height}`;
}, [size]);
return <section ref={size.ref}>{label}</section>;
}

The element comes from React. The resource work still lives in Starbeam and is finalized when React detaches the element resource.

Use useService() for resource-backed state that should live for the app root, not for one component. Wrap the React tree in Starbeam so services have an app-scoped lifetime. See Services and app lifetime for the framework-neutral concept.

import { Starbeam, useReactive, useService } from "@starbeam/react";
import { reactive } from "@starbeam/collections";
import { Resource } from "@starbeam/universal";
import { createRoot } from "react-dom/client";
const SessionService = Resource(() => {
return reactive.object({ userName: "Guest" });
});
createRoot(document.getElementById("root")!).render(
<Starbeam>
<CurrentUser />
</Starbeam>,
);
function CurrentUser(): React.ReactElement {
const session = useService(SessionService);
const userName = useReactive(() => session.userName);
return <p>{userName}</p>;
}

useSetup() and useProp() are public, but they are not the first APIs to reach for in app code. Start with useReactive() for render reads, useResource() for component-lifetime resources, useElementResource() for element-backed resources, and useService() for app-scoped services.