npm package: @rikalabs/effect-react
Description: Effect-native full-stack React meta-framework
Latest version: 0.0.2

# @rikalabs/effect-react

Effect-native full-stack React framework.

React is the view layer. Effect is the execution layer.

```bash
bun add @rikalabs/effect-react effect react react-dom
```

## What it looks like

### Define typed errors and services

```ts
import { Context, Effect, Layer, Schema } from "effect";

// Errors are values — declared as schemas, not thrown exceptions
class RateLimited extends Schema.TaggedError<RateLimited>()("RateLimited", {
  retryAfter: Schema.Number,
}) {}

class Unauthorized extends Schema.TaggedError<Unauthorized>()("Unauthorized", {
  reason: Schema.String,
}) {}

class PostsApi extends Context.Tag("PostsApi")<
  PostsApi,
  {
    readonly list: Effect.Effect<readonly Post[], Unauthorized>;
    readonly create: (input: {
      title: string;
      body: string;
    }) => Effect.Effect<Post, Unauthorized | RateLimited>;
  }
>() {}
```

Every failure is in the type signature. The compiler knows `create` can fail with `Unauthorized | RateLimited` — not `unknown`, not `Error`, not a string.

### Wire it into the runtime

```tsx
import { ManagedRuntime, Layer } from "effect";
import { EffectProvider } from "@rikalabs/effect-react";

const AppLayer = Layer.mergeAll(PostsApiLive, AuthServiceLive, AnalyticsLive);
const AppRuntime = ManagedRuntime.make(AppLayer);

const App = () => (
  <EffectProvider runtime={AppRuntime}>
    <PostsPage />
  </EffectProvider>
);
```

### Query with full Effect power

```ts
import { Effect, Schema } from "effect";
import { defineQuery } from "@rikalabs/effect-react/query";

const Post = Schema.Struct({
  id: Schema.Number,
  title: Schema.String,
  body: Schema.String,
  authorId: Schema.Number,
});
type Post = typeof Post.Type;

export const postsQuery = defineQuery({
  name: "posts",
  input: Schema.Void,
  output: Schema.Array(Post),
  run: () =>
    Effect.gen(function* () {
      const api = yield* PostsApi;
      const analytics = yield* Analytics;
      const posts = yield* api.list;
      yield* analytics.track("posts_viewed", { count: posts.length });
      return posts;
    }),
});
```

The query `run` is a full Effect pipeline — it pulls services from the runtime, composes operations, and the framework handles caching, deduplication, and cancellation automatically.

### Mutate with typed error channels

```ts
import { defineAction } from "@rikalabs/effect-react/framework";
import { invalidateQuery } from "@rikalabs/effect-react/query";

export const createPost = defineAction({
  name: "createPost",
  input: Schema.Struct({ title: Schema.String, body: Schema.String }),
  output: Post,
  error: Schema.Union(Unauthorized, RateLimited), // errors are part of the contract
  handler: (input) =>
    Effect.gen(function* () {
      const api = yield* PostsApi;
      const post = yield* api.create(input); // Unauthorized | RateLimited flows through
      yield* invalidateQuery(postsQuery, undefined);
      return post;
    }),
});
```

The error schema is part of the action contract. The framework validates it at the boundary — if the handler fails with `RateLimited`, the client gets a decoded `RateLimited` instance, not a 500.

### Render by error type — not by guessing

```tsx
import { Suspense } from "react";
import { useQuery } from "@rikalabs/effect-react/query";
import { useAction } from "@rikalabs/effect-react";
import { useForm } from "@rikalabs/effect-react/form";
import { useStoreSelector } from "@rikalabs/effect-react/state";

const PostList = () => {
  const posts = useQuery(postsQuery, undefined);

  if (posts.phase === "failure") {
    const error = posts.error;
    // error is Unauthorized | RateLimited | BoundaryDecodeError | ...
    // exhaustive — the compiler tells you every case
    switch (error._tag) {
      case "Unauthorized":
        return <p>Please sign in to view posts.</p>;
      case "RateLimited":
        return <p>Too many requests. Retry in {error.retryAfter}s.</p>;
      case "BoundaryDecodeError":
        return <p>The server returned unexpected data.</p>;
      default:
        return <p>Something went wrong.</p>;
    }
  }

  if (posts.phase !== "success") return <p>Loading...</p>;

  return (
    <ul>
      {posts.data.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
};
```

No `catch (e: unknown)`. No `instanceof` chains. No string matching. The error is a decoded value with typed fields — `error.retryAfter` is a `number`, not something you fish out of a response body.

### Forms, actions, state — all in one page

```tsx
const CreatePostForm = () => {
  const form = useForm(postForm);
  const action = useAction(createPost);

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault();
        form.submit((values) =>
          Effect.gen(function* () {
            yield* runAction(createPost, values);
          }),
        );
      }}
    >
      <input
        value={form.values.title}
        onChange={(e) => form.setField("title", e.target.value)}
        placeholder="Title"
      />
      <textarea
        value={form.values.body}
        onChange={(e) => form.setField("body", e.target.value)}
        placeholder="Body"
      />
      {form.errors.title && <p>{form.errors.title}</p>}
      <button disabled={action.pending}>
        {action.pending ? "Creating..." : "Create Post"}
      </button>
      {action.error?._tag === "RateLimited" && (
        <p>Slow down — retry in {action.error.retryAfter}s</p>
      )}
      {action.error?._tag === "Unauthorized" && (
        <p>Session expired. Please sign in again.</p>
      )}
    </form>
  );
};

const PostsPage = () => {
  const currentUser = useStoreSelector(authStore, (s) => s.user);

  return (
    <main>
      <h1>Posts</h1>
      {currentUser && <CreatePostForm />}
      <PostList />
    </main>
  );
};
```

One runtime. Errors as values. Automatic cancellation. Schema-validated boundaries. No glue code.

## What you get

- **Query** — cached data fetching with deduplication, stale-while-revalidate, Suspense, and window-focus refetch
- **State** — reactive stores with selectors, derived stores, and change streams
- **Form** — schema-validated forms with typed errors and submit handlers as Effects
- **Actions** — typed mutations with schema validation and HTTP transport
- **Router** — type-safe routes, loaders, navigation, and `<Link>` component
- **Realtime** — typed channels, pub/sub, and presence tracking
- **Framework** — SSR, hydration, file-routing via Vite plugin, layouts, middleware
- **DI** — Effect `Layer` for dependency injection — swap any service for tests without mocks

## Modules

| Module | Import |
|---|---|
| Framework | `@rikalabs/effect-react/framework` |
| Vite Plugin | `@rikalabs/effect-react/framework/vite` |
| Config | `@rikalabs/effect-react/config` |
| Server | `@rikalabs/effect-react/server` |
| Client | `@rikalabs/effect-react/client` |
| Query | `@rikalabs/effect-react/query` |
| State | `@rikalabs/effect-react/state` |
| Form | `@rikalabs/effect-react/form` |
| Router | `@rikalabs/effect-react/router` |
| Grid | `@rikalabs/effect-react/grid` |
| Virtual | `@rikalabs/effect-react/virtual` |
| Realtime | `@rikalabs/effect-react/realtime` |
| Devtools | `@rikalabs/effect-react/devtools` |
| Testing | `@rikalabs/effect-react/testing` |

## Documentation

- [Full documentation](docs/README.md) — tutorials, how-to guides, API reference, and architecture explanations
- [Changelog](CHANGELOG.md)

## License

MIT