React
Let's start with the new part, and also typically the shorter part – implementing the capabilities.
Capability implementation
This is what Weather's core.ts looks like
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);
}
}
// ...
}
It's slightly more complicated, but broadly the same as the Counter's core.
We wrap the CoreFFI (loaded via WASM) and hold on to a React setState
callback, which we use to update the view model whenever the core asks us to
render.
We've truncated the resolve method, because it's fairly long, but the basic
structure is this:
async resolve(id: number, effect: Effect) {
switch (effect.constructor) {
case EffectVariantRender:
// ...
case EffectVariantHttp:
// ...
case EffectVariantKeyValue:
// ...
case EffectVariantLocation:
// ...
}
}
We get a Request, and do a switch on what the requested effect's constructor is
to determine the type. In TypeScript we use instanceof-style constructor
checks, so we can also cast and destructure the operation requested.
We can have a look at what the HTTP branch does:
case EffectVariantHttp: {
const request = (effect as EffectVariantHttp).value;
const response = await http.request(request);
this.respond(id, response);
break;
}
This delegates to http.request(), which does the actual work, and then calls
this.respond() with the result:
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);
}
}
We use async/await to run the HTTP request, then take the response,
serialize it and pass it to core.resolve via respond, which
returns more effect requests. This is perhaps unexpected, but it's the direct
consequence of the Commands async nature. There can easily be a command which
does something along the lines of:
Command::new(|ctx| {
let http_req = Http::get(url).expect_json<Counter>().build().into_future(ctx);
let resp = http_req.await; // effect 1
let counter = resp.map(|result| match result {
Ok(mut response) => match response.take_body() {
Some(counter) => {
Ok(results)
}
None => Err(ApiError::ParseError),
},
Err(_) => Err(ApiError::NetworkError),
});
let _ = KeyValue::set(COUNTER, counter).into_future(ctx).await // effect 2
// ...
ctx.send_event(Event::Done);
})
Once we resolve the http request at the .await point marked "effect 1", this future can
proceed and make a KeyValue request at the "effect 2" .await point. So on the
shell end, we need to be able to respond appropriately.
What we do is loop through those effect requests (there could easily be multiple requests
at once), go through them and recurse—call resolve again to handle each one.
Just for completeness, this is what http.ts looks like:
import type { HttpRequest, HttpResult } from "shared_types/app";
import {
HttpResponse,
HttpHeader,
HttpResultVariantOk,
} from "shared_types/app";
export async function request({
url,
method,
headers,
}: HttpRequest): Promise<HttpResult> {
const request = new Request(url, {
method,
headers: headers.map((header) => [header.name, header.value]),
});
const response = await fetch(request);
const responseHeaders = Array.from(
response.headers.entries(),
([name, value]) => new HttpHeader(name, value),
);
const body = await response.arrayBuffer();
return new HttpResultVariantOk(
new HttpResponse(response.status, responseHeaders, new Uint8Array(body)),
);
}
Not that interesting, it's a wrapper around the browser's native fetch API which takes and
returns the generated HttpRequest and HttpResponse, originally defined in Rust by
crux_http.
The pattern repeats similarly for key-value store and the location capability.
User interface and navigation
It's worth looking at how Weather handles the Workflow navigation in React.
As in the Counter example, the Weather's core holds a ViewModel which we
store in React state via useState, so the component re-renders whenever the
core asks us to.
Here's the root component:
const Home: NextPage = () => {
const [view, setView] = useState(
new ViewModel(new WorkflowViewModelVariantHome(null!, [])),
);
const core: React.RefObject<Core | null> = useRef(null);
const initialized = useRef(false);
useEffect(
() => {
if (!initialized.current) {
initialized.current = true;
init_core().then(() => {
if (core.current === null) {
core.current = new Core(setView);
}
core.current?.update(
new EventVariantHome(new WeatherEventVariantShow()),
);
});
}
},
/*once*/ [],
);
const workflow = view.workflow;
return (
<main>
<section className="section has-text-centered">
<p className="title">Crux Weather Example</p>
<p className="is-size-5">Rust Core, TypeScript Shell (Next.js)</p>
</section>
<section className="container">
{workflow instanceof WorkflowViewModelVariantHome && (
<HomeView
weatherData={workflow.weather_data}
favorites={workflow.favorites}
core={core}
/>
)}
{workflow instanceof WorkflowViewModelVariantFavorites && (
<FavoritesView
favorites={workflow.favorites}
deleteConfirmation={workflow.delete_confirmation}
core={core}
/>
)}
{workflow instanceof WorkflowViewModelVariantAddFavorite && (
<AddFavoriteView searchResults={workflow.search_results} core={core} />
)}
</section>
</main>
);
};
We initialize the WASM core inside a useEffect that runs once, create a
Core instance with the setView callback, and immediately dispatch the
initial Show event to kick things off.
Thanks to the declarative nature of React, we can show the view we need to,
depending on the workflow, using instanceof checks on the workflow variant.
Each branch renders the appropriate component and passes the core ref down.
We could do this differently—core could stay in the root component and we
could pass an update callback via React context, and just the appropriate
section of the view model to each component. You could also use React Router
for navigation. It's up to you how you want to go about it.
Let's look at the HomeView as well, just to complete the picture:
function HomeView({
weatherData,
favorites,
core,
}: {
weatherData: unknown;
favorites: unknown[];
core: React.RefObject<Core | null>;
}) {
const wd = weatherData as any;
const hasData = wd && wd.cod == 200;
const favs = favorites as any[];
return (
<>
<div className="box">
{hasData ? (
<div className="has-text-centered">
<h2 className="title is-4">{wd.name}</h2>
<p className="is-size-1 has-text-weight-bold">
{wd.main.temp.toFixed(1)}°
</p>
{wd.weather?.[0] && (
<p className="is-size-5">{wd.weather[0].description}</p>
)}
<div className="columns is-multiline is-centered mt-4">
<div className="column is-one-third">
<p className="heading">Feels Like</p>
<p>{wd.main.feels_like.toFixed(1)}°</p>
</div>
<div className="column is-one-third">
<p className="heading">Humidity</p>
<p>{Number(wd.main.humidity)}%</p>
</div>
<div className="column is-one-third">
<p className="heading">Wind</p>
<p>{wd.wind.speed.toFixed(1)} m/s</p>
</div>
<div className="column is-one-third">
<p className="heading">Pressure</p>
<p>{Number(wd.main.pressure)} hPa</p>
</div>
<div className="column is-one-third">
<p className="heading">Clouds</p>
<p>{Number(wd.clouds.all)}%</p>
</div>
<div className="column is-one-third">
<p className="heading">Visibility</p>
<p>{Math.floor(Number(wd.visibility) / 1000)} km</p>
</div>
</div>
</div>
) : (
<p className="has-text-centered">Loading weather data...</p>
)}
</div>
{favs.length > 0 && (
<div className="box">
<h3 className="title is-5">Favorites</h3>
{favs.map((fav, i) => {
const w = fav.current;
return (
<div key={i} className="box">
<strong>{fav.name}</strong>
{w ? (
<div className="columns is-multiline mt-2">
<div className="column is-one-third">
<p className="is-size-3 has-text-weight-bold">
{w.main.temp.toFixed(1)}°
</p>
</div>
<div className="column is-one-third">
{w.weather?.[0] && <p>{w.weather[0].description}</p>}
</div>
<div className="column is-one-third">
<p>Humidity: {Number(w.main.humidity)}%</p>
</div>
</div>
) : (
<p className="has-text-grey">Loading...</p>
)}
</div>
);
})}
</div>
)}
<div className="buttons is-centered mt-4">
<button
className="button is-info"
onClick={() =>
core.current?.update(
new EventVariantNavigate(
new WorkflowVariantFavorites(new FavoritesStateVariantIdle()),
),
)
}
>
Favorites
</button>
</div>
</>
);
}
It simply caters for the possible situations in the view model—checking
whether cod === 200 to decide if weather data has loaded—draws the
weather cards with a grid of details, and adds a "Favorites" button which
when clicked calls core.current?.update with the TypeScript equivalent of
the .navigate event we saw earlier in the core.
This is quite a simple navigation setup in that it is a static set of screens we're managing. Sometimes a more dynamic navigation is necessary, but React Router or similar libraries support quite complex scenarios in a declarative fashion, so the general principle of naively projecting the view model into the user interface broadly works even there.
There isn't much more to it, the rest of the app is rinse and repeat. It is relatively rare to implement a new capability, so most of the work is in finessing the user interface. Crux tends to work reasonably well with hot module reloading so you can typically avoid full page reloads for the inner development loop.
What's next
Congratulations! You know now all you will likely need to build Crux apps. The following parts of the book will cover advanced topics, other support platforms, and internals of Crux, should you be interested in how things work.
Happy building!