Skip to content

Latest commit

History

20 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

@mcrovero/effect-react-cache

npm versionlicense: MIT

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.

Install

pnpm add @mcrovero/effect-react-cache effect react

Why

React 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 R requirements and typed errors

Quick start

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"))

Usage

Cache a function with arguments

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 promise

Cache a function without arguments

import{Effect}from"effect"import{reactCache}from"@mcrovero/effect-react-cache/ReactCache"exportconstcachedNoArgs=reactCache(()=>Effect.gen(function*(){yield*Effect.sleep(100)return{ok: trueasconst}}))

Cache with R requirements (Context)

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)})))

API

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

How it works

  • Internally uses react/cache to 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 Effect context (R) is captured at call time, but for a given args tuple the first completed Exit is reused for the lifetime of the current React request/render cache.

Important behaviors

  • 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's and Don'ts

  • 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.
  • Don't: rely on per-call timeouts/cancellation or different Context for the same args. The first call determines the cached outcome and context.

Limitations

  • 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.
  • 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/Channel handles tied to resources.

Testing

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.

Caveats and tips

  • 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 default cache implementation is effectively a passthrough.

Works with Next.js

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.

About

Memoize Effect computations with React’s cache: argument-aware, concurrent-safe, typed.

Resources

Stars

15 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages