A React cache wrapper in Effect
npm install @mcrovero/effect-react-cache

> This library is in early alpha and not yet ready for production use.
Typed helpers to compose React’s cache with Effect in a type-safe, ergonomic way.
``sh`
pnpm add @mcrovero/effect-react-cache effect react
React exposes a low-level cache primitive to memoize async work by argument tuple. 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 R requirements and typed errors
`ts
import { Effect } from "effect"
import { reactCache } from "@mcrovero/effect-react-cache/ReactCache"
// 1) Wrap an Effect-returning function
const fetchUser = (id: string) =>
Effect.gen(function* () {
yield* Effect.sleep(200)
return { id, name: "Alice" as const }
})
const cachedFetchUser = reactCache(fetchUser)
// 2) Use it like any other Effect
await Effect.runPromise(cachedFetchUser("u-1"))
`
`ts
import { Effect } from "effect"
import { reactCache } from "@mcrovero/effect-react-cache/ReactCache"
const getUser = (id: string) =>
Effect.gen(function* () {
yield* Effect.sleep(100)
return { id, name: "Alice" as const }
})
export const cachedGetUser = reactCache(getUser)
// Same args → computed once, then memoized
await Effect.runPromise(cachedGetUser("42"))
await Effect.runPromise(cachedGetUser("42")) // reuses cached promise
`
`ts
import { Effect } from "effect"
import { reactCache } from "@mcrovero/effect-react-cache/ReactCache"
export const cachedNoArgs = reactCache(() =>
Effect.gen(function* () {
yield* Effect.sleep(100)
return { ok: true as const }
})
)
`
`ts
import { Context, Effect } from "effect"
import { reactCache } from "@mcrovero/effect-react-cache/ReactCache"
class Random extends Context.Tag("MyRandomService")
export const cachedWithRequirements = reactCache(() =>
Effect.gen(function* () {
const random = yield* Random
const n = yield* random.next
return n
})
)
// First call for a given args tuple determines the cached value
await Effect.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!
await Effect.runPromise(cachedWithRequirements().pipe(Effect.provideService(Random, { next: Effect.succeed(222) })))
`
`ts`
declare const reactCache: >(
effect: (...args: Args) => Effect.Effect>
) => (...args: Args) => Effect.Effect>
- Input: an Effect-returning functioncache
- Output: a function with the same signature, whose evaluation is cached by argument tuple using React’s
- Internally uses react/cache to memoize by the argument tuple.Effect
- For each unique args tuple, the first evaluation creates a single promise that is reused by all subsequent calls (including concurrent calls).
- The context (R) is captured at call time, but for a given args tuple the first successful or failed promise is reused for the lifetime of the process.
- 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 Scope or create live resources (DB/client handles, file handles, sockets). Acquire resources outside and provide them, or use a Layer.Context
- Don't: rely on per-call timeouts/cancellation or different for the same args. The first call determines the cached outcome and context.
- No scoped resources: Effects requiring Scope are rejected at the type level. React's cache evaluates once and reuses the result, so any scoped resource would be finalized immediately after creation, breaking later callers.Stream
- 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 /Channel handles tied to resources.
When running tests outside a React runtime, you may want to mock react’s cache to ensure deterministic, in-memory memoization:
`ts
import { vi } from "vitest"
vi.mock("react", () => {
return {
cache:
const memo = new Map
return ((...args: Array
const key = JSON.stringify(args)
if (!memo.has(key)) {
memo.set(key, fn(...args))
}
return memo.get(key) as ReturnType
}) as F
}
}
})
`
See test/ReactCache.test.ts for examples covering caching, argument sensitivity, context provisioning, and concurrency.
- The cache is keyed by the argument tuple using React’s semantics. Prefer using primitives or stable/serializable values 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 server-side usage (e.g., React Server Components / server actions) where React’s cache is meaningful.
You can use this library together with @mcrovero/effect-nextjs to cache Effect`-based functions between Next.js pages, layouts, and server components.