Created
June 2, 2025 10:50
-
-
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!
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | |
}, | |
}); | |
}; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.