React (Next.js)
The Next.js shell is TypeScript talking to a WASM blob. Events serialise as bincode, cross the FFI, return effect requests; the shell handles each one, serialises the response, sends it back — the same Request/Response loop as iOS and Android, just in JavaScript. The interesting half of the chapter is how React's render model shapes the shell, because it's the opposite of Leptos's.
Components re-run on every render
In Leptos, a #[component] function runs once, at mount. Signals keep state alive across time; move || closures inside view! re-render fine-grained slots.
React is the other way round. A function component runs every time its state changes — top to bottom, from scratch. Each render produces a new virtual DOM; React diffs against the previous one and patches the actual DOM. Hooks — useState, useRef, useContext, useMemo, useCallback — exist to keep values alive across those reruns and to schedule work at specific moments rather than on every render.
Three consequences for the Crux shell:
- The
Coreinstance has to live in auseRef, not a plain local. A freshnew Core()on every render would break effect resolution mid-flight. - The view model becomes
useState<ViewModel>: React-owned state, whose setter triggers a re-render when the core hands us a new view model. - The dispatcher is wrapped in
useCallbackso its reference is stable across renders. Button handlers that capture it don't then capture a moving target.
Booting the Core
The root of the tree is a CoreProvider:
/**
* Creates the Crux core once, drives its view model into React state, and
* exposes `dispatch` as a stable callback via context.
*/
export function CoreProvider({ children }: { children: ReactNode }) {
const [view, setView] = useState<ViewModel>(new ViewModelVariantLoading());
const coreRef = useRef<Core | null>(null);
const initialized = useRef(false);
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
init_core().then(() => {
if (!coreRef.current) {
coreRef.current = new Core(setView);
}
coreRef.current.update(new EventVariantStart());
});
}, []);
// Stable across renders — `useCallback` with empty deps keeps the same
// reference, so context consumers don't cascade unnecessary renders.
const dispatch = useCallback((event: Event) => {
coreRef.current?.update(event);
}, []);
return (
<DispatchContext.Provider value={dispatch}>
<ViewModelContext.Provider value={view}>
{children}
</ViewModelContext.Provider>
</DispatchContext.Provider>
);
}
Three things happen in this component.
The useRef holds the Core across renders. coreRef.current points at the same instance every time the function runs; assigning once inside the init effect locks it in.
The init useEffect has an empty dep array, which React reads as "run on mount, once". It calls init_core() to download and instantiate the WASM module, constructs the Core, then fires Event::Start to kick off the lifecycle. The initialized.current guard is belt-and-braces for StrictMode (on by default in Next.js), which double-invokes effects in development to surface resource-leak bugs.
The dispatch callback is wrapped in useCallback(_, []) so its reference is stable. Consumers of useDispatch() get the same function every render, which matters when passing it into handlers — otherwise every view update would invalidate every handler and trigger spurious re-renders of memoised children.
The signal model — React edition
Same directional story as Leptos: state flows in, events flow out. The mechanisms are different, but the shape is the same. Two separate contexts:
/**
* Two separate contexts so components that only need `dispatch` don't
* re-render when the view model changes. Mirrors the Leptos split between
* `Signal<ViewModel>` and `UnsyncCallback<Event>`.
*/
const ViewModelContext = createContext<ViewModel | null>(null);
const DispatchContext = createContext<((event: Event) => void) | null>(null);
Splitting them matters. With both view and dispatch in one context, every view change re-renders every consumer of either side — even a component that only needed dispatch. Two contexts means useDispatch() consumers only re-render when the stable callback reference changes, which it never does.
Consumers pull either side with a hook:
export function useViewModel(): ViewModel {
const view = useContext(ViewModelContext);
if (view === null) {
throw new Error("useViewModel must be used within CoreProvider");
}
return view;
}
export function useDispatch(): (event: Event) => void {
const dispatch = useContext(DispatchContext);
if (dispatch === null) {
throw new Error("useDispatch must be used within CoreProvider");
}
return dispatch;
}
Then components fire events with:
const dispatch = useDispatch();
dispatch(new EventVariantActive(new ActiveEventVariantResetApiKey()));
Both directions cross the FFI as bincode. dispatch is just a JS callback wrapping the serialise-and-call-update flow; setView is a React state setter the Core invokes after deserialising the response to Effect::Render.
Projecting with useMemo
The root reads the whole ViewModel and picks off per-stage slices for each screen:
const AppShell = () => {
const view = useViewModel();
// Project the top-level view model into per-stage slices. React's useMemo
// is the coarse equivalent of Leptos's `Memo`: recomputes only when `view`
// changes, but doesn't diff deeper than reference equality.
const onboardVm = useMemo(
() => (view instanceof ViewModelVariantOnboard ? view.value : null),
[view],
);
const homeVm = useMemo(() => pickHome(view), [view]);
const favoritesVm = useMemo(() => pickFavorites(view), [view]);
const failedMessage = useMemo(
() => (view instanceof ViewModelVariantFailed ? view.message : null),
[view],
);
return (
<main className="max-w-xl mx-auto px-4 py-8">
<ScreenHeader
title="Crux Weather"
subtitle="Rust Core, TypeScript Shell (Next.js)"
icon={CloudSun}
/>
{view instanceof ViewModelVariantLoading && (
<Card>
<Spinner message="Loading..." />
</Card>
)}
{onboardVm && <OnboardView model={onboardVm} />}
{homeVm && <HomeView model={homeVm} />}
{favoritesVm && <FavoritesView model={favoritesVm} />}
{failedMessage !== null && (
<Card>
<StatusMessage
icon={WarningCircle}
message={failedMessage}
tone="error"
/>
</Card>
)}
</main>
);
};
useMemo(() => …, [view]) is the React analogue of Leptos's Memo in intent: keep the projection logic explicit and rerun it when view changes. Coarser in one important way — React compares deps by reference, not value. Every Effect::Render produces a freshly deserialised ViewModel, so view is always a new object and the memo always recomputes. For Weather that's fine; the projection functions are cheap.
So the win here is mostly clarity: the stage-picking logic lives in one place, and each child receives the slice it cares about. It is not fine-grained reactivity, and it doesn't by itself make child handlers stable or suppress rerenders deeper in the tree. If you wanted that, you'd reach for React.memo and/or stable callbacks at the relevant component boundary — but this example doesn't need the extra machinery.
Handling effects
The FFI bridge is a single class:
export class Core {
core: CoreFFI;
callback: Dispatch<SetStateAction<ViewModel>>;
constructor(callback: Dispatch<SetStateAction<ViewModel>>) {
this.callback = callback;
this.core = new CoreFFI();
}
update(event: Event) {
const serializer = new BincodeSerializer();
event.serialize(serializer);
const effects = this.core.update(serializer.getBytes());
const requests = deserializeRequests(effects);
for (const { id, effect } of requests) {
this.resolve(id, effect);
}
}
update serialises an event with BincodeSerializer, calls CoreFfi.update (the WASM export), and deserialises the returned bytes into Request objects. Each request carries an id and an effect; we walk them and dispatch each to a per-capability branch.
HTTP looks like this:
case EffectVariantHttp: {
const request = (effect as EffectVariantHttp).value;
const response = await http.request(request);
this.respond(id, response);
break;
}
The handler in http.ts is a fetch wrapper that turns the shared HttpRequest into a browser Request and the Response back into the shared HttpResult. When it returns, we call respond:
respond(id: number, response: Response) {
const serializer = new BincodeSerializer();
response.serialize(serializer);
const effects = this.core.resolve(id, serializer.getBytes());
const requests = deserializeRequests(effects);
for (const { id, effect } of requests) {
this.resolve(id, effect);
}
}
Same recursion as the other shells: serialise the response, call CoreFfi.resolve, and loop through any new effect requests that come back. A Crux command with .await points produces its next effect only after the previous one resolves, so the shell has to keep going until the command's task actually finishes.
The other capabilities — kv, location, secret, time — follow the same shape.
Shared components
Screens compose a set of Tailwind-styled presentational components in src/app/components/common/: Card, Button, IconButton, Spinner, StatusMessage, TextField, ScreenHeader, SectionTitle, Modal. Same names and variant set as the Leptos shell — Button takes primary | secondary | danger; StatusMessage takes neutral | error — so a reader who's seen both shells sees the same vocabulary twice in slightly different dialects. clsx handles the conditional-class plumbing.
The Home screen pulls its slice from props, calls useDispatch once, and wires buttons to events:
export function HomeView({ model }: { model: HomeViewModel }) {
const dispatch = useDispatch();
const lw = model.local_weather;
return (
<>
<Card className="mb-4">
{lw instanceof LocalWeatherViewModelVariantCheckingPermission && (
<StatusMessage
icon={MapPinLine}
message="Checking location permission..."
/>
)}
{lw instanceof LocalWeatherViewModelVariantLocationDisabled && (
<StatusMessage
icon={MapPinLine}
message="Location is disabled. Enable location access to see local weather."
/>
)}
{lw instanceof LocalWeatherViewModelVariantFetchingLocation && (
<Spinner message="Getting your location..." />
)}
{lw instanceof LocalWeatherViewModelVariantFetchingWeather && (
<Spinner message="Loading weather data..." />
)}
{lw instanceof LocalWeatherViewModelVariantFetched && (
<WeatherDetail data={lw.value} />
)}
{lw instanceof LocalWeatherViewModelVariantFailed && (
<StatusMessage
icon={CloudSlash}
message="Failed to load weather."
tone="error"
/>
)}
</Card>
{model.favorites.length > 0 && (
<Card className="mb-4">
<SectionTitle icon={Star} title="Favourites" />
<div className="grid gap-2">
{model.favorites.map((fav, i) => (
<FavoriteWeatherCard key={i} fav={fav} />
))}
</div>
</Card>
)}
<div className="flex justify-center gap-2 mt-4">
<Button
label="Favourites"
icon={Star}
onClick={() =>
dispatch(
new EventVariantActive(
new ActiveEventVariantHome(
new HomeEventVariantGoToFavorites(),
),
),
)
}
/>
<Button
label="Reset API Key"
icon={Key}
variant="secondary"
onClick={() =>
dispatch(
new EventVariantActive(new ActiveEventVariantResetApiKey()),
)
}
/>
</div>
</>
);
}
Icons come from @phosphor-icons/react as typed components (<Key size={18} />). The icon prop on Button takes a phosphor component directly; inside the component it's destructured as { icon: Icon } so JSX can render it with a PascalCase tag.
What's next
That's the Next.js shell. Structurally the same as the other shells — events in, effects out, view model drives the tree. What's distinctive is the render model (top-to-bottom on every state change, hooks as the persistence mechanism) and the two-context split that keeps dispatch-only consumers off the re-render path.
Happy building!