Weftv0.23.1
GitHub

Load Async Data

Goal: render a loading placeholder, then the fetched content, and a fallback if the fetch fails — for data that loads on the client.

Return a Stream<Node> that emits the loading node first and the resolved node second, sequenced with Stream.concat. Handle failure inside the effect with Effect.catchAll, which maps the error to a fallback node.

typescript
import { h } from "@weftui/core";
import { Effect, Stream } from "effect";

interface User {
  id: number;
  name: string;
  email: string;
}

const fetchUser = (id: number): Effect.Effect<User, Error> =>
  Effect.gen(function* () {
    yield* Effect.sleep("1000 millis");
    if (id === 3) return yield* Effect.fail(new Error("User not found"));
    return { id, name: `User ${id}`, email: `user${id}@example.com` };
  });

const UserCard = ({ id }: { id: number }) =>
  Stream.concat(
    Stream.make(h.div({ class: "loading" }, `Loading user ${id}…`)),
    Stream.fromEffect(
      fetchUser(id).pipe(
        Effect.flatMap((user) => h.div({ class: "user-card" }, [h.h3(user.name), h.p(user.email)])),
        Effect.catchAll((error) => h.div({ class: "error" }, `Error: ${error.message}`)),
      ),
    ),
  );

How it works

  • Stream.concat sequences two streams: Stream.make(loadingNode) emits once immediately, then Stream.fromEffect(effect) emits the resolved node when the effect completes. The renderer swaps the DOM in place on the second emission.
  • Effect.flatMap((data) => h.div(...)) builds the content node from the data — h.* returns a Node, which is an Effect, so it composes directly in the pipeline.
  • Effect.catchAll((error) => node) converts the error channel into a fallback node, so the stream always yields something renderable. The failure never escapes to the mount.
  • Parallel loading is automatic: place several async components as siblings and their fetches run concurrently — no orchestration needed.

When to reach for a boundary instead

This is the raw, per-region pattern. When you need one fallback for several async siblings (all-or-nothing), use Boundary.suspend. When the data must be resolved on the server and replayed on hydrate without a second request, use Boundary.rpc instead — this recipe is purely client-side.

See also