Skip to content

Query Search

This example keeps async read state in a model. The View sends search input events and renders the query result.

Runnable app: examples/ts/query-search.

import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
export type Category = "all" | "hardware" | "software" | "services";
export interface Product {
readonly id: string;
readonly title: string;
readonly category: Exclude<Category, "all">;
readonly price: number;
readonly stock: number;
}
export interface SearchInput {
readonly query: string;
readonly category: Category;
}
export interface CatalogApiShape {
readonly search: (
input: SearchInput,
) => Effect.Effect<ReadonlyArray<Product>, unknown>;
}
export class CatalogApi extends Context.Service<
CatalogApi,
CatalogApiShape
>()("@unitflow/example/query-search/CatalogApi") {}
import * as Effect from "effect/Effect";
import { Event, Model, Query, Store } from "@unitflow/react";
import { type Category, CatalogApi } from "./catalog";
export class ProductSearchModel extends Model.Service<ProductSearchModel>()(
"@unitflow/example/query-search",
)({
make: () =>
Effect.gen(function* () {
const query = Store.make("dashboard");
const category = Store.make<Category>("all");
const results = yield* Query.make({
stores: { query, category },
handler: ({ query, category }) =>
Effect.gen(function* () {
const catalog = yield* CatalogApi;
return yield* catalog.search({ query, category });
}),
});
const searchState = Store.combine(
[query, category, results.state],
(query, category, results) => ({
query,
category,
results,
}),
);
return {
inputs: {},
outputs: { results: results.state },
ui: {
searchState,
setQuery: Event.setter(query),
setCategory: Event.setter(category),
reload: results.refresh,
},
};
}),
}) {}
import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
import { View } from "@unitflow/react";
import { type Category, type Product } from "./catalog";
import { ProductSearchModel } from "./model";
const categories: ReadonlyArray<Category> = [
"all",
"hardware",
"software",
"services",
];
const Results = ({
results,
}: {
readonly results: AsyncResult.AsyncResult<ReadonlyArray<Product>, unknown>;
}) =>
AsyncResult.builder(results)
.onWaiting(() => <div>Loading</div>)
.onFailure(() => <div>Catalog unavailable</div>)
.onSuccess((products) =>
products.length === 0 ? (
<div>No matches</div>
) : (
<ul>
{products.map((product) => (
<li key={product.id}>{product.title}</li>
))}
</ul>
),
)
.orNull();
export const ProductSearchApp = View.make(ProductSearchModel, (unit) => (
<main>
<input
value={unit.searchState.query}
onChange={(event) => unit.setQuery(event.currentTarget.value)}
/>
<select
value={unit.searchState.category}
onChange={(event) => unit.setCategory(event.currentTarget.value as Category)}
>
{categories.map((category) => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
<button type="button" onClick={() => unit.reload()}>
Reload
</button>
<Results results={unit.searchState.results} />
</main>
));
import * as Layer from "effect/Layer";
import { Unitflow, UnitflowRuntime } from "@unitflow/react";
import { CatalogApi, catalogApi } from "./catalog";
import { ProductSearchApp } from "./App";
import { ProductSearchModel } from "./model";
const layer = ProductSearchModel.layer.pipe(
Layer.provideMerge(Layer.succeed(CatalogApi, catalogApi)),
);
const runtime = UnitflowRuntime.make(layer);
<Unitflow runtime={runtime} rootModel={ProductSearchModel}>
{(app) => <ProductSearchApp unit={app} />}
</Unitflow>;