Async State

Real applications need to fetch data, submit forms, and handle loading and error states. Effex provides three primitives for this: AsyncReadable for data that loads automatically, Mutation for operations you trigger manually, and AsyncCache for coordinating data across your app.

AsyncReadable

An AsyncReadable wraps an async operation and exposes reactive state for loading, value, and error:

import { AsyncReadable } from "@effex/dom";

const userData = yield* AsyncReadable.make(() =>
  Effect.gen(function* () {
    const response = yield* fetchUser(userId);
    return response.data;
  })
);

// Reactive state properties
userData.isLoading;  // Readable<boolean>
userData.value;      // Readable<Option<User>>
userData.error;      // Readable<Option<Error>>

// Manually trigger refetch
yield* userData.refetch();

// Reset to initial state
yield* userData.reset();

The operation runs immediately when the AsyncReadable is created. The isLoading, value, and error Readables update as the operation progresses.

From Promises

If you’re working with promise-based APIs:

// Simple promise
const data = yield* AsyncReadable.promise(
  () => fetch("/api/data").then((r) => r.json())
);

// With typed error handling
const data = yield* AsyncReadable.tryPromise(
  () => fetch("/api/data").then((r) => r.json()),
  (error) => new ApiError({ cause: error }),
);

Reactive Dependencies

The most powerful pattern: recompute when a source Readable changes.

const userId = yield* Signal.make("alice");

const profile = yield* AsyncReadable.fromReadable(
  (id) => Effect.tryPromise(() => fetchProfile(id))
)(userId);
// Refetches automatically when userId changes

When userId changes from "alice" to "bob", the profile refetches automatically. The isLoading state reflects the new fetch, and the previous value remains available until the new one arrives.

Rendering Async State

Async state works naturally with Effex’s control flow primitives:

import { when, matchOption } from "@effex/dom";

// Show loading spinner
when(userData.isLoading, {
  onTrue: () => Spinner(),
  onFalse: () => $.span(),
});

// Handle the value
matchOption(userData.value, {
  onSome: (user) => UserCard({ user }),  // user is Readable<User>
  onNone: () => $.span({}, $.of("No data")),
});

Mutation

Unlike AsyncReadable, a Mutation doesn’t run until you tell it to. Use it for form submissions, API calls, or any operation triggered by user action:

import { Mutation } from "@effex/dom";

const createUser = yield* Mutation.make((input: CreateUserInput) =>
  Effect.gen(function* () {
    const response = yield* api.createUser(input);
    return response.user;
  })
);

// Same reactive state as AsyncReadable
createUser.isLoading;  // Readable<boolean>
createUser.data;       // Readable<Option<User>>
createUser.error;      // Readable<Option<Error>>

// Execute the mutation
const user = yield* createUser.run({
  name: "Alice",
  email: "alice@example.com",
});

// Reset state
yield* createUser.reset();

From Promises

const createUser = yield* Mutation.promise(
  (input: CreateUserInput) =>
    fetch("/api/users", {
      method: "POST",
      body: JSON.stringify(input),
    }).then((r) => r.json())
);

// With error handling
const createUser = yield* Mutation.tryPromise(
  (input: CreateUserInput) =>
    fetch("/api/users", {
      method: "POST",
      body: JSON.stringify(input),
    }).then((r) => r.json()),
  (error) => new ApiError({ cause: error }),
);

Transforming Results

// Transform the output
const createUserName = Mutation.map(createUser, (user) => user.name);

// Chain mutations
const createAndVerify = Mutation.flatMap(createUser, (user) =>
  Mutation.make(() => api.sendVerificationEmail(user.email))
);

AsyncCache

AsyncCache coordinates async data across your application. It deduplicates requests, caches results, and supports hierarchical invalidation.

import { AsyncCache } from "@effex/dom";

const cache = yield* AsyncCache;

// Get or create a cached async readable
const posts = yield* cache.get(
  ["posts"],
  () => Effect.tryPromise(() =>
    fetch("/api/posts").then((r) => r.json())
  ),
);

If two components request the same cache key, they share the same AsyncReadable — no duplicate fetches.

Seeding with Loader Data

In SSR or SSG apps, you often have data from a server-side loader. Seed the cache so the client doesn’t refetch on hydration:

const posts = yield* cache.get(
  ["posts"],
  () => Effect.tryPromise(() =>
    fetch("/api/posts").then((r) => r.json())
  ),
  { initialData: loaderData.posts },
);

Hierarchical Keys

Cache keys are arrays, which enables prefix-based invalidation:

// Key examples
["posts"]                    // all posts
["posts", "feed"]            // feed posts specifically
["posts", userId]            // posts by user
["users", 42, true]          // compound keys

Invalidation

After a mutation, invalidate related cache entries to trigger a refetch:

// Invalidate all entries starting with ["posts"] — triggers refetch
yield* cache.invalidate(["posts"]);

// Invalidate a specific user's data
yield* cache.invalidate(["users", userId]);

// Remove entries entirely (no refetch)
yield* cache.remove(["posts"]);

// Clear the whole cache
yield* cache.clear();

Typical Pattern: Loader + Cache + Mutation

const FeedPage = (data: { posts: Post[] }) =>
  Effect.gen(function* () {
    const cache = yield* AsyncCache;

    // Seed cache with server-loaded data
    const feedQuery = yield* cache.get(
      ["feed"],
      () => Effect.tryPromise(() =>
        fetch("/?_data=1").then((r) => r.json()).then((r) => r.data)
      ),
      { initialData: data },
    );

    const posts = Readable.map(feedQuery.value, (fetched) =>
      Option.match(fetched, {
        onSome: (f) => f.posts,
        onNone: () => data.posts,
      }),
    );

    // After a mutation, invalidate to refetch
    yield* cache.invalidate(["feed"]);
  });

This pattern gives you server-loaded data on first render, client-side refetching after mutations, and deduplication if multiple components read the same data.