Migrate from manual async patterns to the Effect-based router for type-safe, composable request handling.
The Effect router eliminates boilerplate and prevents entire classes of bugs:
| Problem | Old Pattern | New Pattern |
|---|---|---|
| Error handling | Try-catch + manual state | Typed errors via Effect |
| Timeouts | Promise.race() everywhere |
.timeout("5s") declarative |
| Retries | Custom exponential backoff (3+ implementations) | .retry("exponential") built-in |
| Loading state | Manual useState(isLoading) |
Derived from Effect execution |
| Streaming | EventSourceParserStream + manual batching | Effect.Stream + heartbeat |
| Validation | Manual schema checks | .input(Schema) automatic |
| Cancellation | AbortController leak-prone | Effect interruption safe |
Effort estimate: 2-3 days for full migration. Can be done incrementally.
BEFORE: Manual try-catch + state
// apps/web/src/react/use-session.ts (current pattern)
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
setIsLoading(true);
setError(null);
client.session
.get(sessionId)
.then((session) => setSession(session))
.catch((err) => {
setError(err);
toast.error("Failed to load session");
})
.finally(() => setIsLoading(false));
}, [sessionId]);AFTER: Typed errors via Effect
// apps/web/src/react/use-session.ts (Effect pattern)
import * as Effect from "effect/Effect";
import { ValidationError, TimeoutError, HandlerError } from "@/core/router";
const effect = caller("session.get", { id: sessionId }).pipe(
Effect.catchTag("ValidationError", (err) => {
toast.error(`Invalid input: ${err.issues[0].message}`);
return Effect.fail(err);
}),
Effect.catchTag("TimeoutError", (err) => {
toast.error(`Request timed out after ${err.duration}`);
return Effect.fail(err);
}),
Effect.catchTag("HandlerError", (err) => {
toast.error("Failed to load session");
return Effect.fail(err);
}),
);
const result = await Effect.runPromise(effect);Key differences:
- Errors are typed (not
unknown) - Error handling is composable (pipe operators)
- No manual state management needed
- Toast notifications are co-located with error handling
BEFORE: Manual Promise.race
// apps/web/src/app/page.tsx (current pattern)
const sessionsResponse = await Promise.race([
client.session.list(),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 5000),
),
]);AFTER: Declarative timeout
// apps/web/src/app/page.tsx (Effect pattern)
const route = o({ timeout: "5s" })
.input(Schema.Struct({ limit: Schema.Number }))
.handler(async ({ input, sdk }) => sdk.session.list());
// Timeout is automatic - no manual Promise.race neededBEFORE: Custom exponential backoff (3+ implementations)
// apps/web/src/core/multi-server-sse.ts (current pattern)
const MAX_RETRIES = 10;
const BASE_DELAY = 3000;
let retries = 0;
while (retries < MAX_RETRIES) {
try {
const response = await fetch(url);
if (response.ok) return response;
} catch (error) {
retries++;
const delay = Math.min(BASE_DELAY * Math.pow(2, retries), 30000);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}AFTER: Built-in retry presets
// apps/web/src/core/router/routes.ts (Effect pattern)
const route = o({ retry: "exponential" }) // 100ms base, 2x, 3 retries
.input(Schema.Struct({ url: Schema.String }))
.handler(async ({ input, sdk }) => fetch(input.url));
// Or custom retry:
const route = o({
retry: {
maxAttempts: 10,
delay: "3s",
backoff: 2, // exponential multiplier
},
});BEFORE: Manual useState
// apps/web/src/app/session/[id]/session-layout.tsx (current pattern)
const [isLoading, setIsLoading] = useState(false)
useEffect(() => {
setIsLoading(true)
client.session.get(sessionId)
.then(setSession)
.finally(() => setIsLoading(false))
}, [sessionId])
return isLoading ? <Spinner /> : <SessionView session={session} />AFTER: Derived from Effect execution
// apps/web/src/app/session/[id]/session-layout.tsx (Effect pattern)
const [result, setResult] = useState<Result<Session>>({ state: "loading" })
useEffect(() => {
const effect = caller("session.get", { id: sessionId })
Effect.runPromiseExit(effect).then(exit => {
if (Exit.isSuccess(exit)) {
setResult({ state: "success", data: exit.value })
} else {
setResult({ state: "error", error: Cause.failureOption(exit.cause).value })
}
})
}, [sessionId])
return result.state === "loading" ? <Spinner /> : <SessionView session={result.data} />BEFORE: EventSourceParserStream + manual batching
// apps/web/src/core/multi-server-sse.ts (current pattern)
const events = await client.global.event();
const parser = new EventSourceParserStream();
let eventQueue: Event[] = [];
let debounceTimer: NodeJS.Timeout;
for await (const event of parser.parse(events.stream)) {
eventQueue.push(event);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
eventQueue.forEach((e) => store.handleEvent(e));
eventQueue = [];
}, 16); // Manual 16ms batching
}AFTER: Effect.Stream with heartbeat
// apps/web/src/core/router/routes.ts (Effect pattern)
const route = o({
stream: true,
heartbeat: "30s", // Automatic heartbeat timeout
}).handler(async function* ({ sdk }) {
const events = await sdk.global.event();
for await (const event of events.stream) {
yield event; // Effect.Stream handles batching automatically
}
});Create apps/web/src/core/router/routes.ts with all routes:
import { createOpencodeRoute } from "./builder";
import * as Schema from "effect/Schema";
const o = createOpencodeRoute();
export const routes = {
"session.get": o({ timeout: "30s" })
.input(Schema.Struct({ id: Schema.String }))
.handler(async ({ input, sdk }) => sdk.session.get(input.id)),
"session.list": o({ timeout: "10s" }).handler(async ({ sdk }) =>
sdk.session.list(),
),
"session.prompt": o({
timeout: "5m",
retry: "exponential",
stream: true,
heartbeat: "30s",
})
.input(Schema.Struct({ sessionId: Schema.String, prompt: Schema.String }))
.handler(async function* ({ input, sdk }) {
const response = await sdk.session.prompt({
sessionId: input.sessionId,
prompt: input.prompt,
});
for await (const part of response.stream) {
yield part;
}
}),
};Create apps/web/src/core/router/routes-config.ts:
import { createRouter } from "./router";
import { routes } from "./routes";
export const router = createRouter(routes);For Next.js API routes:
// apps/web/src/app/api/router/route.ts
import { createNextHandler } from "@/core/router/adapters/next";
import { router } from "@/core/router/routes-config";
import { createClient } from "@/core/client";
const handler = createNextHandler({
router,
createContext: async (req) => ({
sdk: createClient(),
}),
});
export { handler as GET, handler as POST };For Server Actions:
// apps/web/src/core/router/actions.ts
"use server";
import { createAction } from "@/core/router/adapters/next";
import { router } from "@/core/router/routes-config";
import { createClient } from "@/core/client";
export const callRoute = createAction({
router,
createContext: async () => ({
sdk: createClient(),
}),
});For Server Components (RSC):
// apps/web/src/app/session/[id]/page.tsx
import { createCaller } from "@/core/router/adapters/direct"
import { router } from "@/core/router/routes-config"
import { createClient } from "@/core/client"
export default async function SessionPage({ params }) {
const caller = createCaller(router, {
sdk: createClient(params.directory),
})
const session = await caller("session.get", { id: params.id })
return <SessionView session={session} />
}Migrate useSession, useMessages, useSendMessage to use the router:
// apps/web/src/react/use-session.ts (AFTER)
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import * as Cause from "effect/Cause";
import { useCallback, useEffect, useState } from "react";
import { useOpenCode } from "./use-provider";
export function useSession(sessionId: string) {
const { caller } = useOpenCode();
const [session, setSession] = useState<Session | null>(null);
const [error, setError] = useState<Error | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
setIsLoading(true);
setError(null);
const effect = caller("session.get", { id: sessionId });
Effect.runPromiseExit(effect).then((exit) => {
if (Exit.isSuccess(exit)) {
setSession(exit.value);
} else {
const err = Cause.failureOption(exit.cause).value;
setError(err instanceof Error ? err : new Error(String(err)));
}
setIsLoading(false);
});
}, [sessionId, caller]);
return { session, error, isLoading };
}Delete:
- Manual
Promise.racetimeout logic - Custom exponential backoff implementations
- Manual loading state management
- Try-catch error handling boilerplate
| Old Pattern | New Pattern | Benefit |
|---|---|---|
try { ... } catch (e) { ... } |
Effect.catchTag("ErrorType", ...) |
Type-safe, composable |
error: unknown |
error: ValidationError | TimeoutError | ... |
Exhaustive error handling |
| Manual error state | Derived from Effect Exit | No state sync bugs |
Example:
// OLD
try {
const session = await client.session.get(id);
setSession(session);
} catch (error) {
if (error instanceof ValidationError) {
setError("Invalid input");
} else if (error instanceof TimeoutError) {
setError("Request timed out");
} else {
setError("Unknown error");
}
}
// NEW
const effect = caller("session.get", { id }).pipe(
Effect.catchTag("ValidationError", (err) => {
console.error("Invalid input:", err.issues);
return Effect.fail(err);
}),
Effect.catchTag("TimeoutError", (err) => {
console.error("Request timed out:", err.duration);
return Effect.fail(err);
}),
Effect.catchTag("HandlerError", (err) => {
console.error("Handler failed:", err.cause);
return Effect.fail(err);
}),
);
const result = await Effect.runPromise(effect);| Old Pattern | New Pattern | Benefit |
|---|---|---|
useState(isLoading) |
Exit.isSuccess(exit) |
Single source of truth |
| Manual try-finally | Effect execution | Guaranteed cleanup |
| Race conditions | Effect guarantees | No stale state |
Example:
// OLD
const [isLoading, setIsLoading] = useState(false);
const [data, setData] = useState(null);
const [error, setError] = useState(null);
useEffect(() => {
setIsLoading(true);
client
.fetch(id)
.then(setData)
.catch(setError)
.finally(() => setIsLoading(false));
}, [id]);
// NEW
const [result, setResult] = useState<Exit<Data, Error>>();
useEffect(() => {
const effect = caller("fetch", { id });
Effect.runPromiseExit(effect).then(setResult);
}, [id]);
const isLoading = result === undefined;
const data = Exit.isSuccess(result) ? result.value : null;
const error = Exit.isFailure(result)
? Cause.failureOption(result.cause).value
: null;| Old Pattern | New Pattern | Benefit |
|---|---|---|
| Custom backoff loop | .retry("exponential") |
Standardized, tested |
| Manual delay calculation | Duration parsing | Type-safe, readable |
| Retry count tracking | Built-in schedule | No off-by-one errors |
Example:
// OLD
const MAX_RETRIES = 3;
const BASE_DELAY = 100;
async function fetchWithRetry(url: string) {
for (let i = 0; i < MAX_RETRIES; i++) {
try {
return await fetch(url);
} catch (error) {
if (i === MAX_RETRIES - 1) throw error;
const delay = BASE_DELAY * Math.pow(2, i);
await new Promise((resolve) => setTimeout(resolve, delay));
}
}
}
// NEW
const route = o({
retry: {
maxAttempts: 3,
delay: "100ms",
backoff: 2,
},
}).handler(async ({ input }) => fetch(input.url));| Old Pattern | New Pattern | Benefit |
|---|---|---|
Promise.race([request, timeout]) |
.timeout("5s") |
Readable, composable |
| Manual timeout error | Typed TimeoutError | Exhaustive handling |
| Timeout per request | Route-level config | Consistent across calls |
Example:
// OLD
const result = await Promise.race([
client.session.get(id),
new Promise((_, reject) =>
setTimeout(() => reject(new Error("Timeout")), 5000),
),
]);
// NEW
const route = o({ timeout: "5s" })
.input(Schema.Struct({ id: Schema.String }))
.handler(async ({ input, sdk }) => sdk.session.get(input.id));| Old Pattern | New Pattern | Benefit |
|---|---|---|
| EventSourceParserStream | Effect.Stream | Backpressure handling |
| Manual event batching | Built-in batching | Correct by default |
| Manual heartbeat | .heartbeat("30s") |
Prevents stale connections |
Example:
// OLD
const events = await client.global.event();
const parser = new EventSourceParserStream();
let eventQueue: Event[] = [];
let debounceTimer: NodeJS.Timeout;
for await (const event of parser.parse(events.stream)) {
eventQueue.push(event);
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
eventQueue.forEach((e) => store.handleEvent(e));
eventQueue = [];
}, 16);
}
// NEW
const route = o({
stream: true,
heartbeat: "30s",
}).handler(async function* ({ sdk }) {
const events = await sdk.global.event();
for await (const event of events.stream) {
yield event; // Effect.Stream handles batching + heartbeat
}
});Use Effect's test utilities:
import * as Effect from "effect/Effect";
import * as Exit from "effect/Exit";
import { describe, it, expect } from "bun:test";
import { createCaller } from "@/core/router/adapters/direct";
import { router } from "@/core/router/routes-config";
describe("session.get route", () => {
it("returns session on success", async () => {
const mockSdk = {
session: {
get: async (id: string) => ({ id, title: "Test" }),
},
};
const caller = createCaller(router, { sdk: mockSdk });
const result = await caller("session.get", { id: "ses_123" });
expect(result).toEqual({ id: "ses_123", title: "Test" });
});
it("handles validation errors", async () => {
const mockSdk = {
/* ... */
};
const caller = createCaller(router, { sdk: mockSdk });
const exit = await Effect.runPromiseExit(
caller("session.get", { id: 123 }), // Wrong type
);
expect(Exit.isFailure(exit)).toBe(true);
const error = Cause.failureOption(exit.cause).value;
expect(error).toBeInstanceOf(ValidationError);
});
it("respects timeout", async () => {
const mockSdk = {
session: {
get: async () => new Promise((resolve) => setTimeout(resolve, 10000)),
},
};
const caller = createCaller(router, { sdk: mockSdk });
const exit = await Effect.runPromiseExit(
caller("session.get", { id: "ses_123" }),
);
expect(Exit.isFailure(exit)).toBe(true);
const error = Cause.failureOption(exit.cause).value;
expect(error).toBeInstanceOf(TimeoutError);
});
});import { renderHook, waitFor } from "@testing-library/react";
import { useSession } from "@/react/use-session";
describe("useSession hook", () => {
it("loads session on mount", async () => {
const { result } = renderHook(() => useSession("ses_123"));
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.session).toBeDefined();
});
it("handles errors", async () => {
const { result } = renderHook(() => useSession("invalid"));
await waitFor(() => {
expect(result.current.error).toBeDefined();
});
});
});Wrong:
// This throws on error - no error handling
const result = await Effect.runPromise(effect);Right:
// This returns Exit - safe error handling
const exit = await Effect.runPromiseExit(effect);
if (Exit.isSuccess(exit)) {
// handle success
} else {
// handle error
}Middleware executes left to right in the chain:
const route = o()
.middleware(authMiddleware) // Runs first
.middleware(loggingMiddleware) // Runs second
.handler(async ({ ctx }) => {
// ctx has both auth and logging context
});Streaming routes don't return the data directly - they return an async iterable:
// WRONG
const parts = await caller("session.prompt", { ... })
console.log(parts) // AsyncIterable, not array
// RIGHT
const parts = await caller("session.prompt", { ... })
for await (const part of parts) {
console.log(part)
}Effect Schema is stricter than Zod by default:
// This will fail validation - extra properties not allowed
const input = { id: "123", extra: "field" };
await caller("session.get", input); // ValidationError
// Use Schema.Struct with optional fields
const schema = Schema.Struct({
id: Schema.String,
extra: Schema.optional(Schema.String),
});Routes automatically handle AbortSignal, but cleanup is your responsibility:
const route = o().handler(async ({ signal, sdk }) => {
const controller = new AbortController();
signal.addEventListener("abort", () => controller.abort());
// Now controller.signal will abort when route is cancelled
return fetch(url, { signal: controller.signal });
});If a streaming route doesn't yield within the heartbeat interval, it fails:
const route = o({
stream: true,
heartbeat: "30s", // Must yield every 30 seconds
})
.handler(async function* ({ sdk }) {
// This will timeout if no yield for 30s
const result = await sdk.session.prompt({ ... })
for await (const part of result.stream) {
yield part // Resets heartbeat timer
}
})If you need to revert:
- Keep old patterns in parallel - Don't delete old code immediately
- Feature flag routes - Use environment variable to switch between old/new
- Gradual migration - Migrate one route at a time, not all at once
Example feature flag:
// apps/web/src/core/router/routes-config.ts
const useEffectRouter = process.env.NEXT_PUBLIC_USE_EFFECT_ROUTER === "true";
export const router = useEffectRouter
? createRouter(effectRoutes)
: createRouter(legacyRoutes);- Create
apps/web/src/core/router/routes.tswith all routes - Create
apps/web/src/core/router/routes-config.tswith router instance - Create
apps/web/src/app/api/router/route.ts(Next.js adapter) - Create
apps/web/src/core/router/actions.ts(Server Actions) - Migrate
useSessionhook - Migrate
useMessageshook - Migrate
useSendMessagehook - Update Server Components to use
createCaller - Delete old
Promise.racetimeout logic - Delete custom exponential backoff implementations
- Delete manual loading state management
- Run full test suite
- Deploy with feature flag enabled
- Monitor error rates for 24 hours
- Remove feature flag and old code
- Effect documentation: https://effect.website
- Effect Schema: https://effect.website/docs/schema/overview
- Router implementation:
apps/web/src/core/router/ - ADR 002:
docs/adr/002-effect-migration.md