This library is in early alpha and not yet ready for production use.
Typed helpers to compose React’s server cache with Effect in a type-safe, ergonomic way.
pnpm add @mcrovero/effect-react-cache effect reactReact exposes a low-level cache primitive to memoize async work by argument tuple during a React Server Component render. This library wraps an Effect-returning function with React’s cache so you can:
- Deduplicate concurrent calls: share the same pending promise across callers
- Memoize by arguments: same args → same result without re-running the effect
- Keep Effect ergonomics: preserve
Rrequirements and typed errors
import{Effect}from"effect"import{reactCache}from"@mcrovero/effect-react-cache/ReactCache"// 1) Wrap an Effect-returning functionconstfetchUser=(id: string)=>Effect.gen(function*(){yield*Effect.sleep(200)return{ id,name: "Alice"asconst}})constcachedFetchUser=reactCache(fetchUser)// 2) Use it like any other Effect// React memoization only happens when this Effect is executed// from an active React server render.awaitEffect.runPromise(cachedFetchUser("u-1"))import{Effect}from"effect"import{reactCache}from"@mcrovero/effect-react-cache/ReactCache"constgetUser=(id: string)=>Effect.gen(function*(){yield*Effect.sleep(100)return{ id,name: "Alice"asconst}})exportconstcachedGetUser=reactCache(getUser)// When executed inside the same React server render:// same args → computed once, then memoizedawaitEffect.runPromise(cachedGetUser("42"))awaitEffect.runPromise(cachedGetUser("42"))// reuses cached promiseimport{Effect}from"effect"import{reactCache}from"@mcrovero/effect-react-cache/ReactCache"exportconstcachedNoArgs=reactCache(()=>Effect.gen(function*(){yield*Effect.sleep(100)return{ok: trueasconst}}))import{Context,Effect}from"effect"import{reactCache}from"@mcrovero/effect-react-cache/ReactCache"classRandomextendsContext.Tag("MyRandomService")<Random,{readonlynext: Effect.Effect<number>}>(){}exportconstcachedWithRequirements=reactCache(()=>Effect.gen(function*(){constrandom=yield*Randomconstn=yield*random.nextreturnn}))// Inside the same React server render, the first call for a given args tuple// determines the cached valueawaitEffect.runPromise(cachedWithRequirements().pipe(Effect.provideService(Random,{next: Effect.succeed(111)})))// Subsequent calls with the same args reuse the first result,// even if a different Context is provided!awaitEffect.runPromise(cachedWithRequirements().pipe(Effect.provideService(Random,{next: Effect.succeed(222)})))declareconstreactCache: <Fextends(...args: Array<any>)=>Effect.Effect<any,any,any>>(effect: F)=>(...args: Parameters<F>)=>ReturnType<F>- Input: an
Effect-returning function - Output: a function with the same signature, whose evaluation is cached by argument tuple using React’s
cache
- Internally uses
react/cacheto memoize by the argument tuple. - For each unique args tuple, the first evaluation creates a single promise that is reused by all subsequent calls (including concurrent calls).
- The
Effectcontext (R) is captured at call time, but for a given args tuple the first completedExitis reused for the lifetime of the current React request/render cache.
- First call wins: for the same args tuple, the first call’s context and outcome (success or failure) are cached. Later calls with a different context still reuse that result.
- Errors are cached: if the first call fails, the rejection is reused for subsequent calls with the same args tuple.
- Concurrency is deduplicated: concurrent calls with the same args share the same pending promise.
- Do: cache pure/idempotent computations that return plain data.
- Do: include discriminators (locale, tenant, user) in the argument tuple when results depend on them.
- Don't: pass effects that require
Scopeor create live resources (DB/client handles, file handles, sockets). Acquire resources outside and provide them, or use aLayer. - Don't: rely on per-call timeouts/cancellation or different
Contextfor the same args. The first call determines the cached outcome and context.
- No scoped resources: Effects requiring
Scopeare rejected at the type level. React'scacheevaluates once and reuses the result, so any scoped resource would be finalized immediately after creation, breaking later callers. - First call wins: For a given args tuple, the first call's context and outcome (success or failure) are cached and reused.
- Context sensitivity: If results depend on request context (logger level, locale, tracer span, etc.), include those discriminators in the arguments or avoid caching.
- Streams/Channels: Don't cache effects that return live
Stream/Channelhandles tied to resources.
When running tests outside a React server render, you may want to mock react’s cache to ensure deterministic, in-memory memoization. React’s default non-server build treats cache as a passthrough, so plain Effect.runPromise(...) calls will not memoize on their own. A simple primitive-oriented mock looks like this:
import{vi}from"vitest"vi.mock("react",()=>{return{cache: <Fextends(...args: Array<any>)=>any>(fn: F)=>{constmemo=newMap<string,ReturnType<F>>()return((...args: Array<any>)=>{constkey=JSON.stringify(args)if(!memo.has(key)){memo.set(key,fn(...args))}returnmemo.get(key)asReturnType<F>})asF}}})See test/ReactCache.test.ts for a more faithful identity-based mock and examples covering caching, argument sensitivity, context provisioning, and concurrency.
- The cache is keyed by the argument tuple using React’s semantics. Prefer primitives or stable object identities as arguments.
- Since the first outcome is cached, design your effects such that this is acceptable for your use case. For context-sensitive computations, include discriminators in the argument list.
- This library is designed for React Server Components. Outside a React server render,
react’s defaultcacheimplementation is effectively a passthrough.
You can use this library together with @mcrovero/effect-nextjs to deduplicate Effect-based functions within the same Next.js server render across pages, layouts, and server components.