@rikalabs/effect-react
v0.0.2
Published
Effect-native full-stack React meta-framework
Downloads
10
Maintainers
Readme
@rikalabs/effect-react
Effect-native full-stack React framework.
React is the view layer. Effect is the execution layer.
bun add @rikalabs/effect-react effect react react-domWhat it looks like
Define typed errors and services
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
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
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
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
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
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
Layerfor 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 — tutorials, how-to guides, API reference, and architecture explanations
- Changelog
License
MIT
