Part 2 available: https://dev.to/titouancreach/part-2-how-i-replaced-trpc-with-effect-rpc-in-a-nextjs-app-router-application-streaming-responses-566c
I recently fell in love with Effect and began incorporating it into my development projects. As my codebase grew, I found myself using Effect more and more throughout the application.
However, I encountered frustration when one of the layers of my application didnโt integrate well with the others.
While almost every part of my application was using Effect schemas, I discovered that tRPC doesnโt play well with Effect schema transformations. The issue lies in how tRPC infers types.
The input type in tRPC is determined by the return type of the validator function, which is also used by the client to send data to the procedure. However, with Effect schemas, the output of the validator function (decodeUnknownSync
) can differ from its input. This is because, in addition to validation, Effect schemas can encode and decode schemas into different types.
I faced a similar issue with the output type. tRPC infers the output type based on the return type of my handler, but I wanted the type to be inferred after the encoding process.
Quick example of this issue:
import { Schema as S } from "effect";
const MySchema = S.Struct({
createdAt: DateFromNumber
})
const MyRouter = createTRPCRouter({
myProcedure: t.procedure
.input(S.decodeUnknownSync(MySchema))
.query(async ({ input }) => {
// ๐ { readonly createdAt: Date }
})
});
// โ But in the client :
api.myRouter.myProcedure.useQuery({ย createdAt: new Date() })
// ๐ but here createdAt is required of type Date instead of type number.
To address this issue, the solution I found was to manually encode and decode data on both the client and server sides whenever necessary.
However, this approach proved to be very error-prone, and a significant portion of my code ended up being dedicated to encoding and decoding rather than focusing on the core business logic.
RPC Layer
Effect offers an RPC layer called @effect/rpc
. Instead of directly calling functions from the frontend, you define requests with schemas and send them. These requests are automatically encoded and decoded at the appropriate stages.
Create an rpc router
The first step is to create an RPC router. To start, let's define a request using a schema. These schemas should be placed in their own file, as they will be imported by both the backend and frontend.
import { Schema as S } from "effect";
export class SayHelloReq extends S.TaggedRequest<SayHelloReq>()("SayHelloReq", {
payload: { name: S.NonEmptyString },
success: S.NonEmptyString,
failure: S.Never,
}) {}
Next, create a service to handle the business logic. I recommend doing this so that you can call the service directly from the backend without relying on the RPC layer.
export class HelloService extends Effect.Service<HelloService>()("HelloService", {
succeed: {
sayHello: (name: string) => Effect.succeed(`Hello ${name}`)
}
}) {}
Afterward, add the service to your managed runtime. If you don't have a managed runtime yet, I suggest creating one and including all the dependencies your application requires.
Finally, set up the router in /app/api/hello/route.ts
.
import { Rpc, RpcRouter } from "@effect/rpc";
import { Effect } from "effect";
export const helloRouter = RpcRouter.make(
Rpc.effect(SayHelloReq, ({ name }) =>
HelloService.pipe(Effect.andThen((_) => _.sayHello(name))),
),
);
export type HelloRouter = typeof helloRouter;
const handler = RpcRouter.toHandlerNoStream(helloRouter);
Api route to handle rpc requests
Once the router is created, we need to route the HTTP requests to it. To do this, we need to export a POST
function from our /app/api/hello/route.ts
file.
import { NextRequest, NextResponse } from "next/server";
import { Effect } from "effect";
import { ServerRuntime } from "~/server/ServerRuntime"; // my managed runtime
export const POST = async (req: NextRequest) => {
const data = await req.json();
return await handler(data).pipe(
Effect.andThen(Response.json),
Effect.tapErrorCause(Effect.logError),
Effect.andThen(ServerRuntime.runPromise)
)
}
Now, we have an endpoint on /api/hello
that can handle a SayHelloReq
.
Client side
On the client side, we need to create an RPC client. A custom HTTP client is necessary because we must explicitly pass cookies; otherwise, authentication won't work during SSR.
Here is the constructor for that client:
"use client";
import { HttpClient, HttpClientRequest } from "@effect/platform";
import * as HttpResolver from "@effect/rpc-http/HttpRpcResolverNoStream";
import { Config, Effect } from "effect";
import * as Resolver from "@effect/rpc/RpcResolver";
// juste because Next.js inline those variables, but we should probably use another config provider
const frontEndConfigHost = Config.succeed(process.env.NEXT_PUBLIC_HOST);
const makeHelloClient = ({ cookies }: { cookies: string }) => {
const helloClient = Config.string("HOST").pipe(
Config.orElse(() => frontEndConfigHost),
Effect.andThen((host) =>
HttpResolver.make<HelloRouter>(
HttpClient.fetchOk.pipe(
HttpClient.mapRequest(
HttpClientRequest.prependUrl(`${host}/api/hello`),
),
HttpClient.mapRequest(HttpClientRequest.setHeader("cookie", cookies)),
),
).pipe(Resolver.toClient),
),
);
return helloClient;
};
Finally, create an instance of the client and pass it through React Context.
export const HelloClientContext = createContext<
ReturnType<typeof makeHelloClient>
>(makeHelloClient({ cookies: "" }));
export default function RpcClientProvider({
cookies,
children,
}: {
cookies: string;
children: React.ReactNode;
}) {
const [client] = useState(() => makeHelloClient({ cookies }));
return (
<HelloClientContext.Provider value={client}>
{children}
</HelloClientContext.Provider>
);
}
Then, use the client we just created with react query directly:
import { HelloClientContext } from "./RpcClientProvider";
export default function MyComponent({ name }: { name: string }) {
const rpcClient = React.useContext(HelloClientContext);
const queryClient = useQueryClient();
const message = useSuspenseQuery({
queryKey: ["hello", name],
queryFn: () => {
return rpcClient.pipe(
Effect.andThen((client) => client(new SayHelloReq({ name: "Foo" }))),
Effect.runPromise,
);
},
});
return <div>{message}</div>;
}
And thatโs all.
I would also recommend wrapping queries in custom hooks and considering wrapping client creation if you have multiple clients.
Here are some links to code that inspired me:
- https://github.com/Effect-TS/website/blob/main/src/pages/api/rpc.ts
- https://github.com/ethanniser/effect-workshop/blob/main/src/part4-whatsnext/snippets/6-rpc.ts
Thanks for reading