Skip to content

Instantly share code, notes, and snippets.

@colelawrence
Created June 2, 2025 10:50
Show Gist options
  • Save colelawrence/237c32d8935989d4d763064ab9cdb306 to your computer and use it in GitHub Desktop.
Save colelawrence/237c32d8935989d4d763064ab9cdb306 to your computer and use it in GitHub Desktop.
Effect RPC + TanStack Query with good types. Customize for your use case! Let me know if you fix failure extraction!
import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
import { RpcResolver, type RpcRouter } from "@effect/rpc";
import { HttpRpcResolver } from "@effect/rpc-http";
import type { AppRouter, JWTAccessToken, OrgID } from "@phosphor/server";
import { type UseMutationOptions, type UseQueryOptions, useMutation, useQuery } from "@tanstack/react-query";
import { Effect, flow, identity, pipe } from "effect";
import { Option } from "effect";
import type * as EffectRequest from "effect/Request";
import type { FiberFailure } from "effect/Runtime";
import React from "react";
import { useBearerToken } from "#/lib/contexts/BearerTokenContext";
import { useCurrentOrgOptional } from "#/lib/contexts/current-org";
const { apiURL } = BUILD_EDITOR_ENV;
const API_RPC_URL = `${apiURL.replace(/\/$/, "")}/rpc`;
const makeClient = (accessToken: null | JWTAccessToken) =>
HttpClient.HttpClient.pipe(
Effect.map(
flow(
HttpClient.mapRequest(HttpClientRequest.prependUrl(API_RPC_URL)),
accessToken == null
? identity
: HttpClient.mapRequest(HttpClientRequest.setHeader("Authorization", `Bearer ${accessToken}`)),
),
),
);
/** @internal */
export const getClientWithToken = (accessToken: null | JWTAccessToken) =>
Effect.map(makeClient(accessToken), HttpRpcResolver.make<AppRouter>).pipe(
Effect.provide(FetchHttpClient.layer),
RpcResolver.toClient,
);
type AnyRpcRequest = RpcRouter.RpcRouter.Request<AppRouter>;
interface UseApiQueryOptions<TReq extends AnyRpcRequest>
extends Omit<UseQueryOptions<EffectRequest.Request.Success<TReq>, EffectRequest.Request.Error<TReq>>, "queryFn"> {
request: TReq;
/** This will try to find the access token relevant to the org user. */
orgID?: OrgID | "none";
}
/**
* Based on React Query (@tanstack/react-query)'s useQuery.
*
* @example
* const { data, isLoading, error, refetch } = useRpcQuery({
* queryKey: ["grocery-list-proposals", groceryList.id],
* request: new GroceryListProposalAPI.ListProposals({
* groceryListId: groceryList.id,
* statuses: [
* ProposalStatus.make("open"),
* ProposalStatus.make("draft"),
* ProposalStatus.make("approved"),
* ProposalStatus.make("rejected"),
* ],
* }),
* });
*/
export const useRpcQuery = <TReq extends AnyRpcRequest>(options: UseApiQueryOptions<TReq>) => {
const { orgID, ...queryOptions } = options;
const client = useRpcClient(orgID);
return useQuery<EffectRequest.Request.Success<TReq>, EffectRequest.Request.Error<TReq>>({
...queryOptions,
queryFn: (context): Promise<EffectRequest.Request.Success<TReq>> => {
return client(options.request, context.signal);
},
});
};
export interface RPCClient {
<TReq extends AnyRpcRequest>(req: TReq, signal?: AbortSignal): Promise<EffectRequest.Request.Success<TReq>>;
}
/** @internal */
export const useRpcClient = (orgID?: undefined | OrgID | "none") => {
const { getBearerToken } = useBearerToken();
const defaultOrgID = useCurrentOrgOptional()?.org.id;
const effectiveOrgID = orgID === "none" ? undefined : (orgID ?? defaultOrgID);
return React.useMemo((): RPCClient => {
const client = getClientWithToken(Option.getOrNull(getBearerToken(effectiveOrgID)));
return function (req, signal) {
return pipe(req, client, (a) => Effect.runPromise(a as any, { signal }).catch(hackyExtractCause) as any);
};
}, [effectiveOrgID, getBearerToken]);
};
const hackyExtractCause = (e: FiberFailure): Promise<never> => {
try {
return Promise.reject((e.toJSON() as any).cause.failure);
} catch (e) {
return Promise.reject(e);
}
};
/**
* Based on React Query (@tanstack/react-query)'s useMutation.
*
* @example
* const saveChangesMutation = useRpcMutation({
* orgID: org.id,
* mutate: (input: GroceryListAPI.SaveGroceryListChanges) => input,
* onSuccess: () => {
* toast({
* title: "Changes Saved",
* description: "Your changes have been saved successfully.",
* });
* },
* });
*
* // in another handler
* saveChangesMutation.mutate(new GroceryListAPI.SaveGroceryListChanges({
* groceryListID: groceryList.id,
* changes: dev.unsavedChanges,
* }));
*/
export const useRpcMutation = <TReq extends AnyRpcRequest, TVariables = void>(
options: Omit<
UseMutationOptions<EffectRequest.Request.Success<TReq>, EffectRequest.Request.Error<TReq>>,
"mutationFn"
> & {
mutate: (variables: TVariables) => TReq;
/** This will try to find the access token relevant to the org user. */
orgID?: OrgID | "none";
},
) => {
const { orgID, ...queryOptions } = options;
const client = useRpcClient(orgID);
return useMutation<
EffectRequest.Request.Success<TReq>,
EffectRequest.Request.Error<TReq>,
TVariables
// @ts-ignore
>({
...queryOptions,
mutationFn: (variables: TVariables): Promise<EffectRequest.Request.Success<TReq>> => {
return pipe(variables, options.mutate, client);
},
});
};
@afonsomatos
Copy link

afonsomatos commented Jun 16, 2025

function unwrapFiberFailure(err: unknown): never {
    if (isFiberFailure(err)) {
        if (err[FiberFailureCauseId]._tag === "Fail") {
            throw err[FiberFailureCauseId].error;
        }
    }
    throw err;
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment