Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/bright-seals-cheat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@mcrovero/effect-react-cache": patch
---

Fix `reactCache` to preserve full `Exit` information, including falsy values and composed causes, instead of collapsing failures into a lossy intermediate shape.

Clarify the React and Next.js caching semantics in the docs and add real Next.js integration coverage for request scoping, cross-component deduplication, and non-render route-handler behavior.
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ jobs:
- uses: actions/checkout@v4
- name: Install dependencies
uses: ./.github/actions/setup
with:
node-version: 24
- name: Create Release Pull Request or Publish
id: changesets
uses: changesets/action@v1
Expand All @@ -31,4 +33,3 @@ jobs:
publish: pnpm changeset-publish
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
coverage/
*.tsbuildinfo
node_modules/
.pnpm-store/
.next/
.DS_Store
tmp/
dist/
Expand All @@ -15,3 +17,4 @@ scratchpad/*
.env.development.local
.env.test.local
.env.production.local
fixtures/next-app/pnpm-lock.yaml
30 changes: 17 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

> 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.
Typed helpers to compose React’s server `cache` with `Effect` in a type-safe, ergonomic way.

### Install

Expand All @@ -15,7 +15,7 @@ pnpm add @mcrovero/effect-react-cache effect react

## Why

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:
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
Expand All @@ -37,6 +37,8 @@ const fetchUser = (id: string) =>
const cachedFetchUser = reactCache(fetchUser)

// 2) Use it like any other Effect
// React memoization only happens when this Effect is executed
// from an active React server render.
await Effect.runPromise(cachedFetchUser("u-1"))
```

Expand All @@ -56,7 +58,8 @@ const getUser = (id: string) =>

export const cachedGetUser = reactCache(getUser)

// Same args → computed once, then memoized
// When executed inside the same React server render:
// same args → computed once, then memoized
await Effect.runPromise(cachedGetUser("42"))
await Effect.runPromise(cachedGetUser("42")) // reuses cached promise
```
Expand Down Expand Up @@ -91,7 +94,8 @@ export const cachedWithRequirements = reactCache(() =>
})
)

// First call for a given args tuple determines the cached value
// Inside the same React server render, the 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,
Expand All @@ -102,9 +106,9 @@ await Effect.runPromise(cachedWithRequirements().pipe(Effect.provideService(Rand
## API

```ts
declare const reactCache: <A, E, R, Args extends Array<unknown>>(
effect: (...args: Args) => Effect.Effect<A, E, NoScope<R>>
) => (...args: Args) => Effect.Effect<A, E, NoScope<R>>
declare const reactCache: <F extends (...args: Array<any>) => Effect.Effect<any, any, any>>(
effect: F
) => (...args: Parameters<F>) => ReturnType<F>
```

- Input: an `Effect`-returning function
Expand All @@ -114,7 +118,7 @@ declare const reactCache: <A, E, R, Args extends Array<unknown>>(

- 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 successful or failed promise is reused for the lifetime of the process.
- 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

Expand All @@ -138,7 +142,7 @@ declare const reactCache: <A, E, R, Args extends Array<unknown>>(

## Testing

When running tests outside a React runtime, you may want to mock `react`’s `cache` to ensure deterministic, in-memory memoization:
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:

```ts
import { vi } from "vitest"
Expand All @@ -159,14 +163,14 @@ vi.mock("react", () => {
})
```

See `test/ReactCache.test.ts` for examples covering caching, argument sensitivity, context provisioning, and concurrency.
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 using primitives or stable/serializable values as arguments.
- 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 server-side usage (e.g., React Server Components / server actions) where React’s `cache` is meaningful.
- 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](https://www.npmjs.com/package/@mcrovero/effect-nextjs) to cache `Effect`-based functions between Next.js pages, layouts, and server components.
You can use this library together with [@mcrovero/effect-nextjs](https://www.npmjs.com/package/@mcrovero/effect-nextjs) to deduplicate `Effect`-based functions within the same Next.js server render across pages, layouts, and server components.
15 changes: 15 additions & 0 deletions fixtures/next-app/app/api/no-react-cache/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Effect } from "effect"
import { cachedRouteHandler, getRouteExecutions } from "../../../lib/cache-fixture"

export const dynamic = "force-dynamic"

export async function GET() {
const first = await Effect.runPromise(cachedRouteHandler("route"))
const second = await Effect.runPromise(cachedRouteHandler("route"))

return Response.json({
first,
second,
executions: getRouteExecutions()
})
}
143 changes: 143 additions & 0 deletions fixtures/next-app/app/behaviors/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { Effect } from "effect"
import {
Random,
cachedComposedCause,
cachedConcurrent,
cachedContext,
cachedDifferentArgs,
cachedErrorDifferentArgs,
cachedErrorSameArgs,
cachedFalsyError,
cachedFalsySuccess,
cachedIdentity,
cachedSameArgs,
cachedSpan,
failuresFromExit,
getComposedCauseExecutions,
getConcurrentExecutions,
getContextExecutions,
getDifferentArgsExecutions,
getErrorDifferentArgsExecutions,
getErrorSameArgsExecutions,
getFalsyErrorExecutions,
getFalsySuccessExecutions,
getIdentityExecutions,
getSameArgsExecutions,
getSpanExecutions,
prettyCauseFromExit
} from "../../lib/cache-fixture"

export const dynamic = "force-dynamic"

export default async function BehaviorsPage() {
const sameFirst = await Effect.runPromise(cachedSameArgs("same"))
const sameSecond = await Effect.runPromise(cachedSameArgs("same"))

const differentFirst = await Effect.runPromise(cachedDifferentArgs("a"))
const differentSecond = await Effect.runPromise(cachedDifferentArgs("b"))

const [concurrentFirst, concurrentSecond] = await Promise.all([
Effect.runPromise(cachedConcurrent("shared")),
Effect.runPromise(cachedConcurrent("shared"))
])

const contextFirst = await Effect.runPromise(
cachedContext().pipe(
Effect.provideService(Random, {
next: Effect.succeed(111)
})
)
)
const contextSecond = await Effect.runPromise(
cachedContext().pipe(
Effect.provideService(Random, {
next: Effect.succeed(222)
})
)
)

const spanFirst = await Effect.runPromise(
cachedSpan().pipe(Effect.withSpan("outer-span"))
)
const spanSecond = await Effect.runPromise(
cachedSpan().pipe(Effect.withSpan("inner-span"))
)

const falsySuccessFirst = await Effect.runPromise(cachedFalsySuccess())
const falsySuccessSecond = await Effect.runPromise(cachedFalsySuccess())

const errorSameArgsFirst = await Effect.runPromiseExit(cachedErrorSameArgs("x"))
const errorSameArgsSecond = await Effect.runPromiseExit(cachedErrorSameArgs("x"))

const errorDifferentArgsFirst = await Effect.runPromiseExit(cachedErrorDifferentArgs("a"))
const errorDifferentArgsSecond = await Effect.runPromiseExit(cachedErrorDifferentArgs("b"))

const falsyError = await Effect.runPromiseExit(cachedFalsyError())
const composedCause = await Effect.runPromiseExit(cachedComposedCause())

const stableInput = { id: "stable" }
const identityStableFirst = await Effect.runPromise(cachedIdentity(stableInput))
const identityStableSecond = await Effect.runPromise(cachedIdentity(stableInput))
const identityFreshFirst = await Effect.runPromise(cachedIdentity({ id: "fresh" }))
const identityFreshSecond = await Effect.runPromise(cachedIdentity({ id: "fresh" }))

const payload = {
sameArgs: {
first: sameFirst,
second: sameSecond,
executions: getSameArgsExecutions()
},
differentArgs: {
first: differentFirst,
second: differentSecond,
executions: getDifferentArgsExecutions()
},
concurrent: {
first: concurrentFirst,
second: concurrentSecond,
executions: getConcurrentExecutions()
},
context: {
first: contextFirst,
second: contextSecond,
executions: getContextExecutions()
},
span: {
first: spanFirst,
second: spanSecond,
executions: getSpanExecutions()
},
falsySuccess: {
first: falsySuccessFirst,
second: falsySuccessSecond,
executions: getFalsySuccessExecutions()
},
errorSameArgs: {
first: failuresFromExit(errorSameArgsFirst),
second: failuresFromExit(errorSameArgsSecond),
executions: getErrorSameArgsExecutions()
},
errorDifferentArgs: {
first: failuresFromExit(errorDifferentArgsFirst),
second: failuresFromExit(errorDifferentArgsSecond),
executions: getErrorDifferentArgsExecutions()
},
falsyError: {
failures: failuresFromExit(falsyError),
executions: getFalsyErrorExecutions()
},
composedCause: {
pretty: prettyCauseFromExit(composedCause),
executions: getComposedCauseExecutions()
},
identity: {
stableFirst: identityStableFirst,
stableSecond: identityStableSecond,
freshFirst: identityFreshFirst,
freshSecond: identityFreshSecond,
executions: getIdentityExecutions()
}
}

return <pre id="payload">{JSON.stringify(payload)}</pre>
}
9 changes: 9 additions & 0 deletions fixtures/next-app/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ReactNode } from "react"

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
3 changes: 3 additions & 0 deletions fixtures/next-app/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export default function HomePage() {
return <main>effect-react-cache Next integration fixture</main>
}
22 changes: 22 additions & 0 deletions fixtures/next-app/app/request-isolation/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Effect } from "effect"
import {
cachedRequestScoped,
getRequestExecutions
} from "../../lib/cache-fixture"

export const dynamic = "force-dynamic"

export default async function RequestIsolationPage() {
const first = await Effect.runPromise(cachedRequestScoped("request"))
const second = await Effect.runPromise(cachedRequestScoped("request"))

return (
<pre id="payload">
{JSON.stringify({
first,
second,
executions: getRequestExecutions()
})}
</pre>
)
}
16 changes: 16 additions & 0 deletions fixtures/next-app/app/tree/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import type { ReactNode } from "react"
import { Effect } from "effect"
import { cachedLayoutPage } from "../../lib/cache-fixture"

export const dynamic = "force-dynamic"

export default async function TreeLayout({ children }: { children: ReactNode }) {
const payload = await Effect.runPromise(cachedLayoutPage("layout-page"))

return (
<>
<pre id="layout-payload">{JSON.stringify(payload)}</pre>
{children}
</>
)
}
10 changes: 10 additions & 0 deletions fixtures/next-app/app/tree/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Effect } from "effect"
import { cachedLayoutPage } from "../../lib/cache-fixture"

export const dynamic = "force-dynamic"

export default async function TreePage() {
const payload = await Effect.runPromise(cachedLayoutPage("layout-page"))

return <pre id="page-payload">{JSON.stringify(payload)}</pre>
}
Loading
Loading