Guides

Stream Server Functions

Render progressive server results without turning the read into a route-refreshing mutation.

Use a Stream Server Function when a server read produces a sequence and the Client Component should render each value as it arrives. Use a standard mutation for writes that navigate or refresh the current route.

Stream a feed

Return an Effect Stream from a read-only Server Function. Every successful branch must return a Stream, including Stream.empty when there is nothing to emit.

// src/feed.ts
"use server";

import { Effect, Schema, Stream } from "effect";
import { EFFRONT } from "./effront";
import { storiesAfter } from "./feed-data";

export const streamFeed = EFFRONT.ServerFn.make({
  input: Schema.Struct({ after: Schema.Natural }),
  handler: ({ after }) =>
    Effect.succeed(
      Stream.fromIterable(storiesAfter(after)).pipe(
        Stream.mapEffect((story) => Effect.sleep(350).pipe(Effect.as(story))),
      ),
    ),
});

Render arriving stories

Wrap the imported Server Function with stream, then update component state for every story.

// src/feed-view.tsx
"use client";

import { stream } from "@effront/core/query";
import { Effect, Stream } from "effect";
import { useEffect, useState } from "react";
import { streamFeed } from "./feed";

const readFeed = stream(streamFeed);

export function FeedView() {
  const [stories, setStories] = useState<{ id: number; title: string }[]>([]);
  const [failed, setFailed] = useState(false);
  const [attempt, setAttempt] = useState(0);

  useEffect(() => {
    const controller = new AbortController();
    setStories([]);
    setFailed(false);
    void Effect.runPromise(
      Stream.runForEach(readFeed({ after: 0 }), (story) =>
        Effect.sync(() => setStories((current) => [...current, story])),
      ),
      { signal: controller.signal },
    ).catch(() => {
      if (!controller.signal.aborted) setFailed(true);
    });
    return () => controller.abort();
  }, [attempt]);

  return (
    <section>
      {failed && (
        <p role="alert">
          Could not load stories.{" "}
          <button onClick={() => setAttempt((value) => value + 1)}>Retry</button>
        </p>
      )}
      <ol>
        {stories.map((story) => (
          <li key={story.id}>{story.title}</li>
        ))}
      </ol>
    </section>
  );
}

Use streamAtom when the UI needs only the latest chunk through Effect Reactivity rather than a list of every story. It has the same optional RegistryProvider setup as queryAtom. Render this component below that provider:

// src/latest-story.tsx
"use client";

import { streamAtom } from "@effront/core/query";
import { useAtom } from "@effect/atom-react";
import { AsyncResult } from "effect/unstable/reactivity";
import { useEffect } from "react";
import { streamFeed } from "./feed";

const latestStory = streamAtom(streamFeed);

export function LatestStory() {
  const [result, run] = useAtom(latestStory);
  useEffect(() => {
    run([{ after: 0 }]);
  }, [run]);

  if (AsyncResult.isInitial(result)) return <p>Waiting for a story…</p>;
  if (AsyncResult.isFailure(result)) return <p role="alert">Could not load a story.</p>;
  return <p aria-live="polite">Latest story: {result.value.title}</p>;
}

Each arriving story replaces the atom's value. Use the stream example above if the UI must retain all stories. An empty stream has no latest value and produces a failure result. For expected outcomes and operational failures, use Error handling for Server Functions. The complete streaming-feed example includes progressive page rendering as well as client retries.