diff --git a/.gitignore b/.gitignore index aada0af1..bca9005e 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,10 @@ dist/ .direnv/ .idea/ packages/*/docs + +## Jekyll Docs +docs/vendor +docs/_site +docs/.bundle +docs/Gemfile.lock + diff --git a/README.md b/README.md index 56f64c72..b81f93f5 100644 --- a/README.md +++ b/README.md @@ -586,3 +586,30 @@ class MyService extends Effect.Service()("MyService", { }) }) {} ``` + + +# Documentation + +## Installation + +Docs are generated with docgen, and require jekyll to be run locally: + +Ensure jekyll and bundler is avaliable on your system. First, run +```bash +gem install bundler jekyll +``` + +or to install this in the gem user space: + +```bash +gem install bundler jekyll --user-install +``` + +Next, navigate to the docs directory at `docs/` and run: + +```bash +bundle install +bundle exec jekyll serve --livereload +``` + +The docs will be available at `http://localhost:4000`. diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 00000000..b49e0a79 --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,3 @@ +source "https://rubygems.org" +gem "github-pages", group: :jekyll_plugins +gem "just-the-docs" diff --git a/docs/_config.yml b/docs/_config.yml index 7bb8f04f..b8914ac9 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -2,8 +2,8 @@ remote_theme: mikearnaldi/just-the-docs search_enabled: true aux_links: "Docs": - - "//tim-smart.github.io/effect-rx" + - "//tim-smart.github.io/effect-atom" "API Reference": - - "//tim-smart.github.io/effect-rx/docs/rx" + - "//tim-smart.github.io/effect-atom/docs/atom" "GitHub": - - "//github.com/tim-smart/effect-rx" + - "//github.com/tim-smart/effect-atom" diff --git a/docs/atom-livestore/AtomLivestore.ts.md b/docs/atom-livestore/AtomLivestore.ts.md index 5df0e7cf..753b66f1 100644 --- a/docs/atom-livestore/AtomLivestore.ts.md +++ b/docs/atom-livestore/AtomLivestore.ts.md @@ -2,6 +2,7 @@ title: AtomLivestore.ts nav_order: 1 parent: "@effect-atom/atom-livestore" +grand_parent: "Reference" --- ## AtomLivestore overview diff --git a/docs/atom-livestore/index.md b/docs/atom-livestore/index.md index 1175e4d9..a700e7b5 100644 --- a/docs/atom-livestore/index.md +++ b/docs/atom-livestore/index.md @@ -2,5 +2,6 @@ title: "@effect-atom/atom-livestore" has_children: true permalink: /docs/atom-livestore -nav_order: 3 +nav_order: 2 +parent: "Reference" --- diff --git a/docs/atom-livestore/index.ts.md b/docs/atom-livestore/index.ts.md index 9c0779bb..d6fbb07d 100644 --- a/docs/atom-livestore/index.ts.md +++ b/docs/atom-livestore/index.ts.md @@ -2,6 +2,7 @@ title: index.ts nav_order: 2 parent: "@effect-atom/atom-livestore" +grand_parent: "Reference" --- ## index overview diff --git a/docs/atom-react/Hooks.ts.md b/docs/atom-react/Hooks.ts.md index 777e0cfe..b5455965 100644 --- a/docs/atom-react/Hooks.ts.md +++ b/docs/atom-react/Hooks.ts.md @@ -2,6 +2,7 @@ title: Hooks.ts nav_order: 1 parent: "@effect-atom/atom-react" +grand_parent: "Reference" --- ## Hooks overview diff --git a/docs/atom-react/ReactHydration.ts.md b/docs/atom-react/ReactHydration.ts.md index 52028af3..23bce0c0 100644 --- a/docs/atom-react/ReactHydration.ts.md +++ b/docs/atom-react/ReactHydration.ts.md @@ -2,6 +2,7 @@ title: ReactHydration.ts nav_order: 3 parent: "@effect-atom/atom-react" +grand_parent: "Reference" --- ## ReactHydration overview diff --git a/docs/atom-react/RegistryContext.ts.md b/docs/atom-react/RegistryContext.ts.md index 6f1d05be..2407480c 100644 --- a/docs/atom-react/RegistryContext.ts.md +++ b/docs/atom-react/RegistryContext.ts.md @@ -2,6 +2,7 @@ title: RegistryContext.ts nav_order: 4 parent: "@effect-atom/atom-react" +grand_parent: "Reference" --- ## RegistryContext overview diff --git a/docs/atom-react/ScopedAtom.ts.md b/docs/atom-react/ScopedAtom.ts.md index bb86bd2f..0de8c036 100644 --- a/docs/atom-react/ScopedAtom.ts.md +++ b/docs/atom-react/ScopedAtom.ts.md @@ -2,6 +2,7 @@ title: ScopedAtom.ts nav_order: 5 parent: "@effect-atom/atom-react" +grand_parent: "Reference" --- ## ScopedAtom overview diff --git a/docs/atom-react/index.md b/docs/atom-react/index.md index bb7bcb43..4b3e5cfc 100644 --- a/docs/atom-react/index.md +++ b/docs/atom-react/index.md @@ -2,5 +2,6 @@ title: "@effect-atom/atom-react" has_children: true permalink: /docs/atom-react -nav_order: 4 +nav_order: 3 +parent: "Reference" --- diff --git a/docs/atom-react/index.ts.md b/docs/atom-react/index.ts.md index 9f16a280..767dac10 100644 --- a/docs/atom-react/index.ts.md +++ b/docs/atom-react/index.ts.md @@ -2,6 +2,7 @@ title: index.ts nav_order: 2 parent: "@effect-atom/atom-react" +grand_parent: "Reference" --- ## index overview diff --git a/docs/atom-solid/Hooks.ts.md b/docs/atom-solid/Hooks.ts.md index fd10b877..ee06ccda 100644 --- a/docs/atom-solid/Hooks.ts.md +++ b/docs/atom-solid/Hooks.ts.md @@ -2,6 +2,7 @@ title: Hooks.ts nav_order: 1 parent: "@effect-atom/atom-solid" +grand_parent: "Reference" --- ## Hooks overview diff --git a/docs/atom-solid/RegistryContext.ts.md b/docs/atom-solid/RegistryContext.ts.md index 2774ed55..3dbb1714 100644 --- a/docs/atom-solid/RegistryContext.ts.md +++ b/docs/atom-solid/RegistryContext.ts.md @@ -2,6 +2,7 @@ title: RegistryContext.ts nav_order: 3 parent: "@effect-atom/atom-solid" +grand_parent: "Reference" --- ## RegistryContext overview diff --git a/docs/atom-solid/index.md b/docs/atom-solid/index.md index defb1de0..284570a4 100644 --- a/docs/atom-solid/index.md +++ b/docs/atom-solid/index.md @@ -2,5 +2,6 @@ title: "@effect-atom/atom-solid" has_children: true permalink: /docs/atom-solid -nav_order: 5 +nav_order: 4 +parent: "Reference" --- diff --git a/docs/atom-solid/index.ts.md b/docs/atom-solid/index.ts.md index 25d21784..fda46815 100644 --- a/docs/atom-solid/index.ts.md +++ b/docs/atom-solid/index.ts.md @@ -2,6 +2,7 @@ title: index.ts nav_order: 2 parent: "@effect-atom/atom-solid" +grand_parent: "Reference" --- ## index overview diff --git a/docs/atom-vue/index.md b/docs/atom-vue/index.md index f2dfa42e..b0c0cbc2 100644 --- a/docs/atom-vue/index.md +++ b/docs/atom-vue/index.md @@ -2,5 +2,6 @@ title: "@effect-atom/atom-vue" has_children: true permalink: /docs/atom-vue -nav_order: 6 +nav_order: 5 +parent: "Reference" --- diff --git a/docs/atom-vue/index.ts.md b/docs/atom-vue/index.ts.md index 746f0863..452dce73 100644 --- a/docs/atom-vue/index.ts.md +++ b/docs/atom-vue/index.ts.md @@ -2,6 +2,7 @@ title: index.ts nav_order: 1 parent: "@effect-atom/atom-vue" +grand_parent: "Reference" --- ## index overview diff --git a/docs/atom/Atom.ts.md b/docs/atom/Atom.ts.md index c79963f0..1d949dbf 100644 --- a/docs/atom/Atom.ts.md +++ b/docs/atom/Atom.ts.md @@ -2,6 +2,7 @@ title: Atom.ts nav_order: 1 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## Atom overview diff --git a/docs/atom/AtomHttpApi.ts.md b/docs/atom/AtomHttpApi.ts.md index 3e73e47c..178934b4 100644 --- a/docs/atom/AtomHttpApi.ts.md +++ b/docs/atom/AtomHttpApi.ts.md @@ -2,6 +2,7 @@ title: AtomHttpApi.ts nav_order: 2 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## AtomHttpApi overview diff --git a/docs/atom/AtomRef.ts.md b/docs/atom/AtomRef.ts.md index 6481dd3d..c9a495b4 100644 --- a/docs/atom/AtomRef.ts.md +++ b/docs/atom/AtomRef.ts.md @@ -2,6 +2,7 @@ title: AtomRef.ts nav_order: 3 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## AtomRef overview diff --git a/docs/atom/AtomRpc.ts.md b/docs/atom/AtomRpc.ts.md index 375540c3..5d7eddef 100644 --- a/docs/atom/AtomRpc.ts.md +++ b/docs/atom/AtomRpc.ts.md @@ -2,6 +2,7 @@ title: AtomRpc.ts nav_order: 4 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## AtomRpc overview diff --git a/docs/atom/Hydration.ts.md b/docs/atom/Hydration.ts.md index 53ddf304..9909d7ef 100644 --- a/docs/atom/Hydration.ts.md +++ b/docs/atom/Hydration.ts.md @@ -2,6 +2,7 @@ title: Hydration.ts nav_order: 5 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## Hydration overview diff --git a/docs/atom/Registry.ts.md b/docs/atom/Registry.ts.md index 50356802..73b2f056 100644 --- a/docs/atom/Registry.ts.md +++ b/docs/atom/Registry.ts.md @@ -2,6 +2,7 @@ title: Registry.ts nav_order: 7 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## Registry overview diff --git a/docs/atom/Result.ts.md b/docs/atom/Result.ts.md index 2ee53bb8..471b6da7 100644 --- a/docs/atom/Result.ts.md +++ b/docs/atom/Result.ts.md @@ -2,6 +2,7 @@ title: Result.ts nav_order: 8 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## Result overview diff --git a/docs/atom/index.md b/docs/atom/index.md index 8376c815..501b55fc 100644 --- a/docs/atom/index.md +++ b/docs/atom/index.md @@ -2,5 +2,6 @@ title: "@effect-atom/atom" has_children: true permalink: /docs/atom -nav_order: 2 +nav_order: 1 +parent: "Reference" --- diff --git a/docs/atom/index.ts.md b/docs/atom/index.ts.md index f381ef5a..b7554dac 100644 --- a/docs/atom/index.ts.md +++ b/docs/atom/index.ts.md @@ -2,6 +2,7 @@ title: index.ts nav_order: 6 parent: "@effect-atom/atom" +grand_parent: "Reference" --- ## index overview diff --git a/docs/guides/atom-react/advanced-topics.md b/docs/guides/atom-react/advanced-topics.md new file mode 100644 index 00000000..d884555e --- /dev/null +++ b/docs/guides/atom-react/advanced-topics.md @@ -0,0 +1,475 @@ +--- +title: "Advanced Topics: The Dependency Graph" +parent: "Using Atom with React" +grand_parent: "Guides" +permalink: /guides/atom-react/advanced-topics +nav_order: 6 +--- + +# Advanced Topics: The Dependency Graph + +The earlier guides treat atoms as a high-level reactive primitive. To use +effect-atom well in larger applications and to confidently choose between +`RegistryProvider`, runtimes, and `ScopedAtom`, it helps to understand the +machinery underneath. This page explains the dependency graph that effect-atom +maintains at runtime, how runtimes plug into it, and how `ScopedAtom` fits +alongside it. + +## Atoms vs Nodes + +An [Atom](/atom/Atom.ts) is a *descriptor*. It's a plain immutable object +with a `read` function (and optionally `write`, `refresh`, etc.). Defining an +atom does not run anything or allocate any state. It just describes how the +value would be produced if it were ever needed. + +State lives in a [Registry](/atom/Registry.ts). When something asks the +registry for an atom's value (via a hook, `registry.get(...)`, or a `get` +call from another atom's read function), the registry creates a *node* for +that atom and caches the result there. Subsequent reads return the cached +value, and subscriptions and lifetimes are attached to the node. + +``` + Atom (descriptor) Node (state in a Registry) + ───────────────── ───────────────────────── + read: ... value + refresh?: ... parents, children + keepAlive: false listeners + idleTTL?: 30_000 lifetime / scope + state: uninitialized | stale | valid +``` + +The same `Atom` referenced in two different registries lives as two +*different nodes*, with independent caches, subscriptions, and lifetimes. +This is why [RegistryProvider](/atom-react/RegistryContext.ts) gives you +isolation per subtree. + +## How the Graph is Built + +When an atom's `read` function calls `get(otherAtom)`, the registry records +a **parent → child** relationship between their nodes: + +- `otherAtom` becomes a *parent* of the calling atom. +- The calling atom becomes a *child* of `otherAtom`. + +Children are notified when a parent's value changes; parents are kept alive +as long as they have at least one child (or listener) holding them. + +Consider: + +```typescript +const A = Atom.make(1); +const B = Atom.make((get) => get(A) + 1); +const C = Atom.make((get) => get(B) * 10); +``` + +After mounting `C`, the graph looks like: + +``` + A (1) ── child ──► B (2) ── child ──► C (20) + ◄── parent ── ◄── parent ── +``` + +When `A` is updated to `2`: + +1. `A`'s node calls `setValue(2)`. +2. Because the value changed, `A` invalidates its children and `B` is marked stale. +3. Any listeners on `B` (or active children of `B`) cause `B` to recompute, + which produces `3`. `B` then invalidates `C`. +4. `C` recomputes to `30` and notifies its listeners (React components). + +Two important details: + +- **Equality short-circuits**. `setValue` uses `Equal.equals` from + `effect/Equal` to skip propagation when the new value is structurally equal + to the old. Atoms that produce `Data`-shaped values or `Schema`-decoded + classes benefit automatically. +- **Lazy propagation**. If a stale node has no listeners and no active + children, it doesn't recompute eagerly, it stays stale and only recomputes + when next read. This is what makes the graph cheap; idle parts of the tree + don't do work. + +## Node Lifetimes and Cleanup + +When a node is created, the registry checks whether its atom is `keepAlive`. +If not, a removal is scheduled but only takes effect when the node has +**no listeners and no children**. + +This means an atom is automatically retained as long as: + +1. A component is mounted that uses it (listener), **or** +2. Another atom is using it as a dependency (child), **or** +3. The atom is explicitly marked `Atom.keepAlive`. + +If all of those drop to zero, the node is eligible for removal. The +[idleTTL](/atom/Atom.ts#setidlettl) option lets you keep nodes around for a +grace period after they become idle, useful for avoiding refetch flicker +when a user navigates away and back. + +When a node *is* removed, the registry: + +1. Sets its state to `removed`, clears all listeners. +2. Disposes its **lifetime** which runs any finalizers added during the + read function (closing scopes, cancelling effects, unsubscribing streams). +3. Removes itself as a child from each of its parents. Parents that become + eligible for removal in turn are scheduled. + +The lifetime is the key abstraction: anything an atom does that needs cleanup +(`Effect.scoped`, stream subscriptions, manual `addFinalizer` calls, the +runtime atom's layer scope) is tied to it. + +## Runtimes Are Part of the Graph + +[`Atom.runtime(layer)`](/atom/Atom.ts#runtime) doesn't create a separate +mechanism for managing layers it produces an atom whose value is a built +`Runtime`. That runtime atom participates in the same dependency graph as +any other atom. + +Specifically, the factory creates two atoms: + +- **`AppRuntime.layer`**: an atom that returns the composed Layer (with the + framework's `Reactivity.layer` merged in). This is held in the graph and + read by the runtime atom below. +- **`AppRuntime`** itself: the runtime atom. Its `read` function: + 1. Calls `get(AppRuntime.layer)` to fetch the layer, establishing a + parent → child link. + 2. Builds the layer with the framework's shared + [`Layer.MemoMap`](https://effect.website/docs/requirements-management/layer-memoization/), + into a `Scope` provided by the node's lifetime. + 3. Returns a `Result.Success(runtime)`. + +When you write `AppRuntime.atom(effect)`, you get a `readable` atom whose +`read` function does `get(AppRuntime)`, takes the runtime out of the +`Result`, and runs your effect through it. That call also establishes a +parent → child relationship, so: + +``` + AppRuntime.layer ── child ──► AppRuntime ── child ──► MessagesAtom + ◄── parent ── ◄── parent ── +``` + +Several consequences fall out of this: + +- **Layer-building is lazy.** The layer is not built until some atom reads + the runtime atom. If you never use it in a given registry, the layer + scope never opens. +- **Layer-building is per-registry.** Each `Registry` builds its own scope + off the layer, which is why a `RegistryProvider` gives you genuine + isolation in tests. Your test registry has a completely separate runtime + from production. +- **The `MemoMap` is shared.** If two `Atom.runtime` instances reference the + same sub-layer, Effect's `Layer.MemoMap` deduplicates it. Use this when + several runtimes share common services. +- **Refreshing the runtime rebuilds the layer.** Calling + `registry.refresh(AppRuntime)` invalidates the runtime atom, which + disposes its lifetime, closing the layer's scope and releasing all of + the layer's resources. The next read rebuilds it. +- **Refreshing an atom built from the runtime does *not* rebuild the layer.** + Refreshing `MessagesAtom` invalidates only its own node. When it + recomputes, `get(AppRuntime)` returns the cached, still-valid runtime, and + only your effect re-runs. This is the right semantics for "refresh this + data." + +This is also why test-mocking via `RegistryProvider`'s `initialValues` works: +seeding a value for `AppRuntime` or `AppRuntime.layer` replaces what the +graph sees at that node, and every downstream atom transparently uses the +substituted value. + +## SSR and the Module-Scoped Default Registry + +[RegistryContext](/atom-react/RegistryContext.ts) is created with a real +`Registry` as its *default value*: + +```typescript +export const RegistryContext = React.createContext(Registry.make({ + scheduleTask, + defaultIdleTTL: 400 +})); +``` + +Because that `Registry.make(...)` call runs at module load, the resulting +instance is **module-scoped**. There's one per Node process / browser tab, +and it persists for the lifetime of that process. Every component that +doesn't have a closer `RegistryProvider` above it shares it. + +For a client-only SPA this is fine: there's exactly one user, one tab, and +one registry that lives for the lifetime of the page. For SSR it's +actively dangerous. + +### Why the default registry breaks under SSR + +A Node server typically reuses the same module across concurrent requests. +If every request reads atoms through the module-scoped default registry, +you get a single graph shared by all of them: + +``` + ┌─────────────────────────┐ + │ default singleton │ + │ Registry (module- │ + │ scoped) │ +Request A ──read──┤ userAtom → {id:1} ├──read── Request B +Request B ──write─┤ userAtom → {id:2} │ + │ ... │ + └─────────────────────────┘ +``` + +Three concrete failure modes follow: + +- **State leakage between users.** Request A's render writes + `userAtom = Result.Success({id:1})` to the registry. Request B reads + the same atom moments later and sees user 1's data, because the cache + is shared. This is a real correctness and privacy bug, not a + theoretical one. +- **Races during concurrent rendering.** Two requests that touch the same + atom both call `setValue` — they race; one wins; both renders end up + with the wrong data. +- **Cache growth across requests.** The registry's idle TTL gives nodes a + grace period before removal. Under load, the cache accumulates faster + than it evicts. + +Per-request registries (via `RegistryProvider` or a manual +`RegistryContext.Provider` wrapping the SSR render) eliminate all three, +because each request has its own graph. + +### What's actually shared vs per-request + +There are two distinct things to keep straight: + +1. **Atom descriptors**: `const messagesAtom = Atom.make(...)`. These are + module-level constants. They're imported once per Node process and stay + around for its entire lifetime. They're shared across every request on + that server instance, which is fine. That's just how JS modules work. +2. **Atom values**: the cached results stored in a `Registry`. *This* is + what needs to be isolated per request. + +The module-scoped singleton in `RegistryContext` shares (2) across +requests, which is the source of the trouble. Sharing (1) is unavoidable +and harmless. + +### "Wouldn't I want some things shared across requests?" + +Yes, but at a different level. You don't want to rebuild a database +connection pool, an HTTP client, or any other expensive layer service for +every single request. Effect-atom already handles this for you, through +the [`Layer.MemoMap`](/atom/Atom.ts#defaultmemomap): + +```typescript +export const defaultMemoMap: Layer.MemoMap = globalValue( + "@effect-atom/atom/Atom/defaultMemoMap", + () => Effect.runSync(Layer.makeMemoMap) +); + +export const runtime: RuntimeFactory = globalValue( + "@effect-atom/atom/Atom/defaultContext", + () => context({ memoMap: defaultMemoMap }) +); +``` + +`defaultMemoMap` is a process-global `globalValue`. There is exactly one for the +lifetime of the process, and every runtime atom passes it to +`Layer.buildWithMemoMap` when building its layer. When Request A's +registry builds the runtime, the layer is built into the MemoMap. When +Request B's registry builds the same runtime, the MemoMap returns the +already-built services rather than rebuilding them. + +So the SSR picture looks like this: + +``` + Process-wide Per-request + ───────────── ──────────── + module-level Atom descriptors Request A's Registry + default Layer.MemoMap ◄──build──── Request B's Registry + (DB pool, HTTP client, ...) Request C's Registry +``` + +You get the best of both worlds: cheap setup (no rebuilding services per +request) and correct isolation (no atom state leakage). + +### When you really do want cross-request value caching + +The rare case where you genuinely want an atom's *cached value* to survive +across requests is almost always better expressed differently: + +1. **Just a constant.** If it's truly static, declare it as data, not as + an atom. +2. **A layer service with internal caching.** Write a `CacheService` that + lives in your layer; it'll be shared via the MemoMap. The atom that + reads from it gets fresh values per request, but the cache itself is + process-wide. +3. **Framework-level caching.** Next.js's `fetch` cache, ISR, the edge + cache, or a CDN. These tools are purpose-built for cross-request + caching and handle invalidation properly. +4. **A dedicated cache layer.** Redis, an in-memory LRU service, etc., + wrapped as a Layer like any other service. + +What you almost never want is to lean on registry state for cross-request +caching. It'll bite you the moment one of those "atoms" turns out to be +user-scoped or has any per-request derivation in its dependency graph. + +### The recommended SSR pattern + +For Next.js App Router (and any other framework that calls your root +component fresh per request), mount the provider in the root layout: + +```typescript +"use client"; +import { RegistryProvider } from "@effect-atom/atom-react"; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + + {children} + + + ); +} +``` + +Each render call gets its own `RegistryProvider`, which `useRef`s a +freshly-made `Registry`. The first read of the runtime atom builds the +layer through `defaultMemoMap`, so the underlying services are reused +across requests, but the atom values (caches, subscriptions, lifetimes) +are per-request. + +When the request finishes and the React tree is discarded, +`RegistryProvider`'s 500ms-delayed cleanup eventually disposes the +registry's scope. That releases the request's claim on its MemoMap +entries; the underlying layer stays alive as long as some other request +references it. + +## Where `ScopedAtom` Fits + +This section covers the mechanics of how `ScopedAtom` interacts with the +graph. For the practical guide on when to reach for one, common patterns, +and gotchas, see [Scoped Atoms](/guides/atom-react/scoped-atoms). + +`ScopedAtom` lives one level *above* the dependency graph. It is a React +Context wrapper, not a registry primitive. + +A `ScopedAtom` wraps a factory `() => Atom` (or `(input) => Atom`) so +that each `` boundary calls the factory once and stores the +resulting atom in a ref. Components inside that boundary call `.use()` to +read the atom from React Context. The returned atom is then used with the +normal hooks and lives in whatever `Registry` the subtree is using. + +So: + +| Concern | `RegistryProvider` | `ScopedAtom` | +|---|---|---| +| What gets swapped | The `Registry` instance (all node state) | One `Atom` identity per Provider boundary | +| Where it lives | Inside the dependency graph | One level above — produces atoms that go *into* the graph | +| Affects | All atoms in the subtree share fresh state | Only the specific atoms wrapped in `ScopedAtom` | +| Atom references | Same atom object app-wide | Different atom object per Provider boundary | + +A useful way to picture it: + +``` + // swaps the Registry (graph) + // chooses which Foo Atom is "Foo" here + // .use() returns the Foo Atom + // that gets stored as a node + // in the current Registry +``` + +### When to reach for `ScopedAtom` + +The dependency graph already supports the common "swap a service" case +through `Atom.runtime` and `initialValues`. `ScopedAtom` is useful when atom +*identity* itself needs to vary per subtree, situations the graph alone +cannot express: + +- **Per-subtree state**. Two panels need their own independent count, form, + or selection. Each rendered under a different `ScopedAtom.Provider` + resolves to a different atom object, with its own node in the registry. +- **Input-parameterized atoms**. A factory like `(userId) => Atom.make(...)` + produces a different atom for each user id. Wrap it in a `ScopedAtom` and + pass the user id via ``. +- **Test substitution at the atom level**. When seeding a `Result` via + `initialValues` isn't enough (for example, you want to replace a stream + atom with one that emits scripted values), provide a `ScopedAtom` whose + factory returns the substitute atom in tests. + +### A worked example + +Suppose you have a configurable dashboard panel and want each panel to have +its own independent filter state without forcing callers to construct an +atom per panel: + +```typescript +import { Atom } from "@effect-atom/atom"; +import { ScopedAtom } from "@effect-atom/atom-react"; + +const ScopedFilter = ScopedAtom.make(() => Atom.make("")); + +function Panel() { + const filterAtom = ScopedFilter.use(); + const [filter, setFilter] = useAtom(filterAtom); + return setFilter(e.target.value)} />; +} + +export function Dashboard() { + return <> + + + ; +} +``` + +Each `` calls the factory once and remembers the +returned atom. The two panels read *different* atoms from `useAtom`, so they +have independent state, but they share the same `Registry` (and therefore +the same runtime, services, and surrounding graph). + +### Ergonomics: ScopedAtoms are not Atoms + +A `ScopedAtom` is not itself an `Atom`, so helpers that expect `Atom` +(`Atom.map`, `useAtomValue`, `useAtomRefresh`, ...) can't take it directly. +You'll typically resolve once at the top of a component: + +```typescript +function UserProfile() { + const userAtom = ScopedUser.use(); + const user = useAtomValue(userAtom); + const refresh = useAtomRefresh(userAtom); + // ... +} +``` + +Or wrap into a custom hook for ergonomic reuse: + +```typescript +const useUser = () => useAtomValue(ScopedUser.use()); +const useUserRefresh = () => useAtomRefresh(ScopedUser.use()); +``` + +The atom resolved from `.use()` is otherwise identical to any other atom in +your graph. + +## Putting It Together + +A mental checklist for choosing where in this stack to operate: + +1. **Need to swap a service for a subtree?** Use `RegistryProvider` with + `initialValues` seeded against `AppRuntime.layer` (or `AppRuntime` + itself). The graph absorbs the change with no other code modifications. +2. **Need to swap an atom's value for a subtree?** Use `RegistryProvider` + with `initialValues` for the leaf atom. Its effect is bypassed entirely. +3. **Need to swap an atom's *identity* for a subtree?** Use `ScopedAtom`. + Reach for it when the same atom descriptor isn't right everywhere, + either because each subtree needs its own copy, or because the atom's + construction depends on tree-local input. +4. **Need genuine isolation between subtrees** (e.g. test isolation, two + independent app instances embedded in one page)? Use `RegistryProvider` + per subtree. + +All four cases compose; you can nest `ScopedAtom` providers inside a single +`RegistryProvider`, or use separate `RegistryProvider`s for entirely +independent state graphs. + +## See Also + +- [Services, Registries, and Testability](/guides/atom-react/services-registry): the practical guide for swapping services. +- [Scoped Atoms](/guides/atom-react/scoped-atoms): the practical guide for per-subtree atom identity. +- [Atom](/atom/Atom.ts): the descriptor type and combinators. +- [Registry](/atom/Registry.ts): the state container. +- [ScopedAtom](/atom-react/ScopedAtom.ts): API reference for `ScopedAtom`. diff --git a/docs/guides/atom-react/basic-types.md b/docs/guides/atom-react/basic-types.md new file mode 100644 index 00000000..aefa0a40 --- /dev/null +++ b/docs/guides/atom-react/basic-types.md @@ -0,0 +1,121 @@ +--- +title: "Basic Types and Effect Patterns" +parent: "Using Atom with React" +grand_parent: "Guides" +permalink: /guides/atom-react/basic-types +nav_order: 1 +--- + +# Basic Types and Effect Patterns + +## Atoms + +An [Atom](/atom/Atom.ts) is the core type in effect-atom. Atoms are reactive +state containers that are shared across all components that subscribe to them. + +```typescript +import { Atom } from "@effect-atom/atom-react"; + +export const counterAtom = Atom.make(0); +``` + +These are application singletons by default — any component reading +`counterAtom` will share the same underlying state. + +### Effect-backed Atoms + +`Atom.make` also accepts an `Effect`, which lets you express asynchronous +work, dependency injection via services, error handling, and more — anything +the [Effect](https://effect.website/) ecosystem supports. The resulting atom +holds a [Result](/guides/atom-react/result-types) that tracks whether the +effect is loading, succeeded, or failed. + +```typescript +import { Atom } from "@effect-atom/atom-react"; +import { Effect } from "effect"; + +export const fetchMessages = Effect.gen(function* () { + const api = yield* APIService; + const res = yield* api.getMessages(); + const data = yield* Effect.tryPromise(() => res.json()); + // For brevity we omit response validation here; in a real app you'd + // use Schema or similar to decode the JSON into a known shape. + return data.messages; +}).pipe( + // ...providers, + // ...retry logic, + // ...error handling, + // ...etc. +); + +export const MessagesAtom = Atom.make(fetchMessages); +``` + +> See [Services, Registries, and Testability](/guides/atom-react/services-registry) +> for how to provide service implementations to effect-backed atoms. + +### Derived Atoms + +Atoms can reference other atoms, building a graph of derived state. It's +common to take state from one atom, compute something from it, and return a +new atom that components can subscribe to — this lets you share derived +state across components without duplicating logic. + +To read from other atoms, pass a function to [Atom.make](/atom/Atom.ts#make). +Its first argument is `get`, which can be called with another atom to read +its current value. + +```typescript +import { Atom, Result } from "@effect-atom/atom-react"; +import { MessagesAtom } from "../"; + +export const MessagesCountAtom = Atom.make((get) => { + const messagesResult = get(MessagesAtom); + if (!Result.isSuccess(messagesResult)) { + return 0; + } + return messagesResult.value.length; +}); +``` + +Because `MessagesAtom` is effect-backed, `get(MessagesAtom)` returns a +`Result`, not the raw messages. We use `Result.isSuccess` to narrow the type +and access `.value` safely. Reading `.value` without the guard would fail +typecheck, because the value isn't guaranteed to be present. + +See [Result Types & Consuming Atom State](/guides/atom-react/result-types) and +the reference for [Result refinement methods](/atom/Result.ts#refinements) +for more information on how to work with Results. + +### Refreshing Atoms + +To refresh an atom and force it to recompute its effect (especially useful for +asynchronous atoms), use the [useAtomRefresh](/atom-react/Hooks.ts#useatomrefresh) +hook. It returns a callback that, when called, invalidates the atom's cached +value and re-runs its effect. + +```typescript +import { useAtomRefresh } from "@effect-atom/atom-react"; +import { MessagesAtom } from "../"; + +export default function MessagesComponent() { + const refreshMessages = useAtomRefresh(MessagesAtom); + + return
+ +
; +} +``` + +Since atoms are application singletons, refreshing an atom updates every +component subscribed to it. For asynchronous atoms, the refresh kicks off a +waiting state on the atom's `Result` while the new effect runs. + +> If you're working *outside* of React (e.g. in a script, test, or effect +> pipeline), the [Atom.refresh](/atom/Atom.ts#refresh) function returns an +> `Effect` you can run against a registry directly. +> Inside a component, always prefer the hook since it wires up the registry +> automatically. + +See [Result Types](/guides/atom-react/result-types) to learn more about +Results and how to render components based on the atom's computation state. diff --git a/docs/guides/atom-react/index.md b/docs/guides/atom-react/index.md new file mode 100644 index 00000000..1ab9f3a4 --- /dev/null +++ b/docs/guides/atom-react/index.md @@ -0,0 +1,26 @@ +--- +title: "Using Atom with React" +parent: "Guides" +permalink: /guides/atom-react +nav_order: 1 +has_children: true +--- + +# Using Atom with React + +The core concept of Atom is to create reactive state containers that are easy to use in your application. For +those familiar with [Jotai](https://jotai.dev/react), many concepts will be similar. + +To understand the basics of using Effect, start with these core sections to familiarize yourself with the system. +It is assumed you have some familiarity with Effect, but if you need to brush up, take a look at the [Effect Documentation](https://effect.website/docs/). + +# Why Effect-Atom? + +A quick and dirty explanation for using effect-atom if you're completely new to the ecosystem is: + +1. Effectful computations are generally good at abstracting a lot of dependencies, and allows for a flexible +way to drop in implementations of Services. +2. React Query is generally great, for handling async data your code depends on, but if you're already using Effect +on the server side, you can reuse most of your code logic client side, specifically adding Schemas and shapes +for client side validation of data (if you're into that kind of thing). +3. Effect-Atom is heavily inspired by Jotai, allowing you to model state graphs, updates, and derivations in a nice way, with the added benefit of lifting State into the Effect typesystem. diff --git a/docs/guides/atom-react/result-types.md b/docs/guides/atom-react/result-types.md new file mode 100644 index 00000000..6d7f9824 --- /dev/null +++ b/docs/guides/atom-react/result-types.md @@ -0,0 +1,77 @@ +--- +title: "Result Types & Consuming Atom State" +parent: "Using Atom with React" +grand_parent: "Guides" +permalink: /guides/atom-react/result-types +nav_order: 2 +--- + +## Result Types & Consuming Atom State + +To use data from an atom in React, the hooks return a [Result](/atom/Result.ts). +A `Result` is a tagged union that can be in one of three states: `Initial`, +`Success`, or `Failure`. The `Initial` state is important for asynchronous +atoms: it represents the "loading" phase before the async work has resolved. + +```typescript +import { useAtomValue, Result } from "@effect-atom/atom-react"; +import { MessagesCountAtom } from "./..."; + +export const MyComponent = () => { + const messagesCountResult = useAtomValue(MessagesCountAtom); + + return
+ {Result.isInitial(messagesCountResult) &&

Loading...

} + {Result.isFailure(messagesCountResult) &&

Something went wrong

} + {Result.isSuccess(messagesCountResult) &&

{messagesCountResult.value}

} +
; +}; +``` + +Note you cannot access a Result's `value` without the `Result.isSuccess` +refinement, because the value is not guaranteed to be present in the other +states. + + +### Using Result.builder to Render + +The above pattern is common when working with async atoms. To make the code +more concise, use [Result.builder](/atom/Result.ts#builder), a fluent +API that maps each state to a React node: + +```typescript +import { useAtomValue, Result } from "@effect-atom/atom-react"; +import { MessagesCountAtom } from "./..."; + +export const MyComponent = () => { + const messagesCountResult = useAtomValue(MessagesCountAtom); + + return
+ { + Result.builder(messagesCountResult) + .onInitial(() =>

Loading...

) + .onWaiting(() =>

Refreshing...

) + .onFailure(() =>

Something went wrong

) + .onSuccess((messagesCount) =>

{messagesCount}

) + .render() + } +
; +}; +``` + +The builder is checked at the type level. Once you've handled `onSuccess`, +the result type narrows so that the next branch can no longer be a success, +and so on. The terminal `.render()` returns the React node produced by the +first matching branch. + +Useful branches include: + +- `onInitial(f)`: atom hasn't produced its first value yet. +- `onWaiting(f)`: a value is present but a refresh is in flight. +- `onSuccess((value) => ...)`: success, with the value passed directly. +- `onFailure((cause) => ...)`: any failure (typed errors *and* defects). +- `onError((error) => ...)`: only typed errors (the `E` channel). +- `onDefect((defect) => ...)`: only unexpected errors (`Effect.die`, exceptions, etc.). + +See [Result.builder](/atom/Result.ts#builder) for the full set of +branches. diff --git a/docs/guides/atom-react/scoped-atoms.md b/docs/guides/atom-react/scoped-atoms.md new file mode 100644 index 00000000..ab069a52 --- /dev/null +++ b/docs/guides/atom-react/scoped-atoms.md @@ -0,0 +1,333 @@ +--- +title: "Scoped Atoms" +parent: "Using Atom with React" +grand_parent: "Guides" +permalink: /guides/atom-react/scoped-atoms +nav_order: 5 +--- + +# Scoped Atoms + +By default, atoms are application singletons — every component that reads +`messagesAtom` is reading the same node in the registry. That's the right +default for most cases, but sometimes you need the *atom* itself to vary +across the React tree: each panel having its own filter, each route having +its own user-scoped atom, or a test wanting to substitute a stubbed atom. + +[ScopedAtom](/atom-react/ScopedAtom.ts) is the tool for that. It wraps an +atom factory in a React Context so each `` boundary resolves to +its own atom. + +## When to Reach for a Scoped Atom + +Use a Scoped Atom when atom *identity* needs to vary per subtree. The most +common cases: + +- **Per-subtree state** — two instances of the same component each need + their own independent piece of state (selection, form, filter, pagination). +- **Input-parameterized atoms** — the atom depends on tree-local data + (a route param, a user id, an entity id) that the component shouldn't + have to thread through every helper. +- **Atom-level test substitution** — you want to wholesale replace a + particular atom with a stub in tests, without changing the production + code that reads it. + +If instead you only need to swap services or seed a known value into the +registry, prefer +[RegistryProvider with `initialValues`](/guides/atom-react/services-registry). +Use the comparison table in +[Advanced Topics](/guides/atom-react/advanced-topics#where-scopedatom-fits) +to decide. + +## The API + +A Scoped Atom has four pieces: + +```typescript +interface ScopedAtom
, Input = never> { + use(): A; + Provider: React.FC<{ children?: React.ReactNode; value: Input }>; + Context: React.Context; +} +``` + +You create one with [ScopedAtom.make](/atom-react/ScopedAtom.ts#make), +passing a factory that produces the underlying atom: + +```typescript +import { Atom } from "@effect-atom/atom"; +import { ScopedAtom } from "@effect-atom/atom-react"; + +// No-input factory +const ScopedFilter = ScopedAtom.make(() => Atom.make("")); + +// Input-parameterized factory +const ScopedUserMessages = ScopedAtom.make((userId: string) => + AppRuntime.atom(Effect.gen(function*() { + const api = yield* APIService; + return yield* api.getUserMessages(userId); + })) +); +``` + +The factory runs **once per Provider instance**, the first time the Provider +renders. The resulting atom is cached in a ref and reused on subsequent +renders of that same Provider boundary. + +Components inside the Provider call `.use()` to get the atom from React +Context: + +```typescript +function MyComponent() { + const filterAtom = ScopedFilter.use(); + const [filter, setFilter] = useAtom(filterAtom); + // ... +} +``` + +`.use()` is a hook and follows the rules of hooks. It throws an error if +called outside a matching Provider. + +## Use Case 1: Independent State per Subtree + +Suppose you have a `Panel` component that owns its own filter input, and the +parent renders multiple panels. With a regular atom, all panels would share +one filter. With a Scoped Atom, each panel gets its own: + +```typescript +import { Atom } from "@effect-atom/atom"; +import { ScopedAtom, useAtom } from "@effect-atom/atom-react"; + +const ScopedFilter = ScopedAtom.make(() => Atom.make("")); + +function Panel({ title }: { title: string }) { + const filterAtom = ScopedFilter.use(); + const [filter, setFilter] = useAtom(filterAtom); + + return ( +
+

{title}

+ setFilter(e.target.value)} + placeholder="Filter..." + /> +
+ ); +} + +export function Dashboard() { + return ( + <> + + + + + + + + ); +} +``` + +Each `` runs the factory once and stores its own +`Atom.make("")`. The two panels read *different* atoms via `useAtom`, so +they have independent filter state — and yet the consumer code is identical. + +## Use Case 2: Input-Parameterized Atoms + +A factory can take an input from the Provider. This is the standard pattern +when an atom's identity depends on data only available at runtime — for +example, a user id from a route: + +```typescript +import { Effect } from "effect"; +import { Atom, Result } from "@effect-atom/atom"; +import { ScopedAtom, useAtomValue } from "@effect-atom/atom-react"; +import { AppRuntime, APIService } from "..."; + +const ScopedUserMessages = ScopedAtom.make((userId: string) => + AppRuntime.atom(Effect.gen(function*() { + const api = yield* APIService; + return yield* api.getUserMessages(userId); + })) +); + +function UserMessagesView() { + const messagesAtom = ScopedUserMessages.use(); + const result = useAtomValue(messagesAtom); + + return Result.builder(result) + .onInitial(() =>

Loading...

) + .onFailure(() =>

Failed to load

) + .onSuccess((messages) => ( +
    {messages.map((m) =>
  • {m.text}
  • )}
+ )) + .render(); +} + +export function UserRoute({ userId }: { userId: string }) { + return ( + + + + ); +} +``` + +### Gotcha: the factory only runs once per Provider instance + +`ScopedAtom.Provider` caches the resulting atom in a ref. **Changing the +`value` prop on an already-mounted Provider will not produce a new atom** — +the factory only runs the first time. If you need the atom to change when +the input changes, **pass the input as `key` as well**, like above +(``). React will +unmount and remount the Provider when the key changes, which causes the +factory to run again with the new value. + +If you forget the `key`, navigating from user `A` to user `B` will continue +to render messages for user `A` — a subtle bug to watch for. + +## Use Case 3: Substituting Atoms in Tests + +`RegistryProvider`'s `initialValues` handles most test mocking by seeding a +result for a known atom. When that isn't enough — for example, you want to +swap a stream-backed atom for one that emits scripted values, or you want +the same component code to read a completely different atom in tests — a +Scoped Atom lets the consumer code stay unchanged while the Provider varies. + +Define the atom behind a Scoped wrapper: + +```typescript +// production code +export const ScopedMessagesAtom = ScopedAtom.make(() => + AppRuntime.atom(fetchMessages) +); + +// app root + + + +``` + +Components use `ScopedMessagesAtom.use()` everywhere instead of importing a +fixed atom. In tests, supply a different factory through a test-only +Provider: + +```typescript +// test setup +const TestMessagesAtom = ScopedAtom.make(() => + Atom.make(Effect.succeed(["mock", "messages"])) +); + +// Wrap the component using the test Scoped Atom... +// Note: components must use TestMessagesAtom.use() in this scenario. +``` + +A cleaner pattern is to pass the atom itself as the input: + +```typescript +type MessagesAtom = Atom.Atom>>; + +export const ScopedMessagesAtom = ScopedAtom.make( + (atom) => atom +); + +// production + + + + +// test + + + +``` + +The factory is just `(atom) => atom`, and the Provider's `value` prop carries +the actual atom — production passes the real one, tests pass a stub. + +## Working with Scoped Atoms + +A `ScopedAtom
` is **not** itself an `Atom`, so helpers that take an +`Atom` (`Atom.map`, `useAtomValue`, `useAtomRefresh`, `Atom.refresh`, ...) +can't take a `ScopedAtom` directly. You always go through `.use()` first. + +### Pattern: resolve once at the top of a component + +The cleanest approach is to resolve the underlying atom at the top of the +component and pass it around as a normal `Atom`: + +```typescript +function UserProfile() { + const userAtom = ScopedUser.use(); + const user = useAtomValue(userAtom); + const refresh = useAtomRefresh(userAtom); + const setUser = useAtomSet(userAtom); + // ... +} +``` + +### Pattern: custom hooks + +If many components consume the same Scoped Atom, hide the indirection behind +a custom hook: + +```typescript +const useUser = () => useAtomValue(ScopedUser.use()); +const useUserSet = () => useAtomSet(ScopedUser.use()); +const useUserRefresh = () => useAtomRefresh(ScopedUser.use()); +``` + +Consumers then just call `useUser()`, and won't even know there's a Scoped +Atom underneath. + +### Pattern: composing inside the factory + +If you find yourself doing `Atom.map(scoped.use(), fn)` repeatedly, push the +derivation inside the factory so it happens once per Provider: + +```typescript +const ScopedUser = ScopedAtom.make((id: string) => makeUserAtom(id)); +const ScopedUserName = ScopedAtom.make((id: string) => + Atom.map(makeUserAtom(id), (u) => u.name) +); +``` + +### Pattern: using `Context` directly + +In non-component code that has access to a React fiber (for example a +custom hook), you can read the Context directly: + +```typescript +const userAtom = React.useContext(ScopedUser.Context); +``` + +This is what `.use()` does internally, with the addition of a clearer error +message when the value is missing. + +## How Scoped Atoms Interact with the Registry + +A Scoped Atom doesn't have its own state container — the atom returned by +the factory is registered in whatever `Registry` is currently in scope +(typically the one provided by `RegistryProvider`). That means: + +- Each Provider instance produces a unique atom *identity* — but all of + those atoms still live in the same `Registry` (with its own node, cache, + and subscriptions for each). +- When the Provider unmounts, the atom becomes unreferenced from the React + side. If the atom has `keepAlive: false` and no other subscribers or + children, its node becomes eligible for removal from the registry. If + there are subscribers in other subtrees, it stays alive. +- `RegistryProvider` and `ScopedAtom` compose freely. You can have a single + app-wide `RegistryProvider` and many Scoped Atom boundaries inside it, or + nested `RegistryProvider`s each with their own Scoped Atom usage. + +See [Advanced Topics: Where ScopedAtom Fits](/guides/atom-react/advanced-topics#where-scopedatom-fits) +for the underlying mechanics. + +## See Also + +- [Services, Registries, and Testability](/guides/atom-react/services-registry) — most service-mocking and per-subtree isolation is handled there. +- [Advanced Topics: The Dependency Graph](/guides/atom-react/advanced-topics) — how Scoped Atoms relate to the underlying graph. +- [ScopedAtom](/atom-react/ScopedAtom.ts) — the API reference. diff --git a/docs/guides/atom-react/services-registry.md b/docs/guides/atom-react/services-registry.md new file mode 100644 index 00000000..a2b04d2b --- /dev/null +++ b/docs/guides/atom-react/services-registry.md @@ -0,0 +1,395 @@ +--- +title: "Services, Registries, and Testability" +parent: "Using Atom with React" +grand_parent: "Guides" +permalink: /guides/atom-react/services-registry +nav_order: 4 +--- + +# Services, Registries, and Testability + +In [Basic Types](/guides/atom-react/basic-types), we introduced the core building +block of effect-atom but intentionally left out how to provide Services. + +```typescript +import { Atom } from "@effect-atom/atom-react"; +import { Effect } from "effect" + +export const fetchMessages = Effect.gen(function* () { + const api = yield* APIService; + const res = yield* api.getMessages(); + const data = yield* Effect.tryPromise(() => res.json()); + return data.messages; +}).pipe( + // ...providers, + // ...retry logic, + // ...error handling, + // ...etc. +) + +export const MessagesAtom = Atom.make(fetchMessages); +``` + +Your first instinct may be to create a service and provide it directly to +each atom's effect. + +```typescript +import { Atom } from "@effect-atom/atom-react"; +import { Effect, Context, Layer } from "effect"; + +export class APIService extends Context.Tag("MyApp/APIService")< + APIService, + { + readonly getMessages: () => Effect.Effect>; + } +>() {} + +export const APIServiceLive = Layer.succeed(APIService, { + getMessages: () => + Effect.tryPromise(() => + fetch("/api/messages").then((res) => res.json() as Promise>) + ).pipe(Effect.orDie), +}); + +export const fetchMessages = Effect.gen(function* () { + const api = yield* APIService; + return yield* api.getMessages(); +}).pipe( + Effect.provide(APIServiceLive), +); + +export const MessagesAtom = Atom.make(fetchMessages); +``` + +This is a valid way to provide services to atoms, and it makes a service that +other atoms can also use. The downside is that it tightly couples the live +implementation of each service to the atom. If you wanted to mock this +component in a development or test environment, the atom is hard-linked to +the live service. + +This generally will not scale well, since you have to register each service +at the atom layer. Luckily, effect-atom provides a way to register an entire +layer once at the root of your application, and lets you swap it out per +React subtree. + +### Registry Providers and Layers + +A [Registry](/atom/Registry.ts) is a per-subtree container for atom state. It +holds the cached value, subscriptions, and lifetime of every atom that has +been read inside it. The [RegistryProvider](/atom-react/RegistryContext.ts) +component provides a fresh `Registry` instance via React Context, so every +subtree can have its own independent state graph. + +#### Do I need to mount a `RegistryProvider`? + +Not always. `RegistryContext` is created with a default value, a real +`Registry` instance, so atoms work out of the box even if no +`RegistryProvider` is mounted above them: + +```typescript +// This is what RegistryContext does at module load: +const defaultRegistry = Registry.make({ defaultIdleTTL: 400 }); +export const RegistryContext = React.createContext(defaultRegistry); +``` + +That default registry is **module-scoped**: there's one per Node process / +browser tab, and every component that doesn't have a closer +`RegistryProvider` above it shares it. Whether that's safe depends on +your environment: + +| Environment | Need a `RegistryProvider`? | Why | +|---|---|---| +| Client-only SPA (Vite, CRA, single-page apps) | Optional | One user, one tab, one registry — the default singleton is fine. | +| SSR (Next.js App Router, Remix, etc.) | **Yes** | The module-scoped registry would leak state across concurrent requests on the same Node process. Each request needs its own registry. | +| Tests | **Yes** (or a manual `RegistryContext.Provider`) | Each test needs an isolated registry so cached values, subscriptions, and lifetimes don't bleed between tests. | +| Disposable subtree (a modal, sandbox, embedded app) | Optional | Mounting one gives the subtree its own state graph that's torn down when it unmounts. | + +For an SSR app, the typical place to mount one is in the root layout. See +[Advanced Topics: SSR and the Module-Scoped Default Registry](/guides/atom-react/advanced-topics#ssr-and-the-module-scoped-default-registry) +for why this is required and how shared layer services are still reused +across requests via the [Layer.MemoMap](/atom/Atom.ts#defaultmemomap). + +#### Mounting `RegistryProvider` + +For SSR (or anytime you want explicit registry control), mount one at the +root of your application: + +```typescript +"use client"; + +import { RegistryProvider } from "@effect-atom/atom-react"; + +export default function Layout({ + children +}: { + children: React.ReactNode; +}) { + return ( + + + + {children} + + + + ); +} +``` + +Next, define a single `Layer` that composes all your services, and turn it +into a runtime atom using [Atom.runtime](/atom/Atom.ts#runtime): + +```typescript +import { Layer } from "effect"; +import { Atom } from "@effect-atom/atom-react"; +import { APILayerLive } from "./APILayer"; +// ...other layer imports + +export const ApplicationLayerLive = Layer.mergeAll( + APILayerLive, + // ...other layers +); + +export const AppRuntime = Atom.runtime(ApplicationLayerLive); +``` + +`Atom.runtime(layer)` produces an *atom-runtime* that is itself an atom whose value +is the built `Runtime` for that layer, plus factory methods (`.atom`, +`.fn`, `.pull`, `.subscriptionRef`, ...) for creating atoms that consume the +layer's services. The first time the runtime atom is read inside a registry, +the layer is built into a scope tied to that registry's lifetime. When the +`RegistryProvider` unmounts, the scope is closed and the layer's resources +are released. + +### Using the Runtime with Registered Services + +Atoms that depend on the layer's services are created with the runtime's +constructor methods rather than `Atom.make`. The signatures are nearly +identical, but the runtime methods automatically thread the runtime through +so the effect can access any service in the layer: + +```typescript +import { Effect } from "effect"; +import { AppRuntime } from "./runtime"; +import { APIService } from "./services/APIService"; + +export const fetchMessages = Effect.gen(function* () { + const api = yield* APIService; + return yield* api.getMessages(); +}); + +export const MessagesAtom = AppRuntime.atom(fetchMessages); +``` + +`Atom.make` is replaced with the runtime's +[atom method](/atom/Atom.ts#atomruntime-interfaceatom); the runtime also +exposes `.fn`, `.pull`, `.subscriptionRef`, and `.subscribable` for other +flavors of atom. + +Components that consume `MessagesAtom` use the same hooks as any other atom. +You don't have to thread layers or runtimes through your component tree at +all. The runtime is part of the dependency graph (see +[Advanced Topics](/guides/atom-react/advanced-topics)). + +### Mocking Atoms in Tests with `initialValues` + +For testing a component that consumes `MessagesAtom`, the simplest approach is +to skip the service plumbing entirely and seed a test-only registry with the +value you want the atom to have. `Registry.make` accepts `initialValues` — +an iterable of `[atom, value]` pairs — and pre-populates the cache before any +component reads from it. + +Because effect-backed atoms hold a [Result](/guides/atom-react/result-types), +you seed them with one of [Result.success](/atom/Result.ts#success), +[Result.fail](/atom/Result.ts#fail), or +[Result.initial](/atom/Result.ts#initial). + +```typescript +import { Registry, Result } from "@effect-atom/atom"; +import { RegistryContext } from "@effect-atom/atom-react"; +import { render } from "@testing-library/react"; +import ComponentThatUsesMessages from "..."; +import { MessagesAtom } from "..."; + +const mockMessages = [ + { id: "1", text: "Hello" }, + { id: "2", text: "World" }, +]; + +it("renders mocked messages", () => { + const registry = Registry.make({ + initialValues: [ + [MessagesAtom, Result.success(mockMessages)], + ], + }); + + render( + + + + ); + + // ...assertions +}); +``` + +Because the atom's value is already in the registry, its underlying effect +is never run — no API call is made, no service is required. The component +renders with `Result.Success(mockMessages)` immediately on first render. + +The same pattern covers the other states an async atom can be in: + +```typescript +// Loading state +[MessagesAtom, Result.initial(true)] + +// Error state +[MessagesAtom, Result.fail(new Error("network failed"))] +``` + +> **Why `RegistryContext.Provider` instead of `RegistryProvider` here?** +> `RegistryProvider` is the React-aware wrapper used in production — it +> keeps the registry stable via `useRef`, schedules registry tasks +> through React's scheduler, and delays disposal by 500ms on unmount. +> None of those matter in tests, and they actually get in the way. Tests +> render once and tear down; constructing the `Registry` directly is +> clearer and gives you full control. +> +> The equivalent `RegistryProvider` form also works, if you prefer +> consistency with production code: +> +> ```typescript +> [MessagesAtom, Result.success(mockMessages)], +> ]}> +> +> +> ``` + +#### A `renderWithRegistry` helper + +If your test suite does this a lot, wrap it once: + +```typescript +import type { ReactNode } from "react"; +import { Registry } from "@effect-atom/atom"; +import type { Atom } from "@effect-atom/atom"; +import { RegistryContext } from "@effect-atom/atom-react"; +import { render } from "@testing-library/react"; + +export const renderWithRegistry = ( + ui: ReactNode, + initialValues: Iterable, any]> = [] +) => { + const registry = Registry.make({ initialValues }); + return render( + {ui} + ); +}; +``` + +Then each test becomes: + +```typescript +renderWithRegistry(, [ + [MessagesAtom, Result.success(mockMessages)], +]); +``` + +#### Derived Atoms + +If you have derived atoms that combine state from other atoms with services, +the cleanest test still preloads the atom under test directly. Consider: + +```typescript +export const UserMessagesAtom = AppRuntime.atom((get) => + Effect.gen(function* () { + const userResult = get(UserAtom); + if (!Result.isSuccess(userResult)) return []; + const api = yield* APIService; + return yield* api.getUserMessages(userResult.value.id); + }) +) +``` + +This atom is slightly different from the one above, because we need the +user's ID from `UserAtom` to make a request to the API. Note the `atom` +method provides both the `get` context (to read from other atoms) and a +generator (to abstract service calls). + +To test a component that uses `UserMessagesAtom`, you can still just preload +it directly: + +```typescript +renderWithRegistry(, [ + [UserMessagesAtom, Result.success([ /* ...messages */ ])], +]); +``` + +You don't need to mock `UserAtom`, `APIService`, or the layer — because +`UserMessagesAtom` already has its value cached, none of its dependencies +are read. + +### When You Need the Effect to Actually Run + +Sometimes you want to exercise the atom's effect itself — to verify it +calls the service correctly, to test retry or error behavior end to end, or +to integration-test multiple atoms together. In that case, mock the layer. + +You seed `AppRuntime.layer` with a test layer, but remember to merge +[`Reactivity.layer`](https://github.com/Effect-TS/effect/blob/main/packages/experimental/src/Reactivity.ts) +back in — the runtime relies on it internally for `withReactivity` and +`reactivityKeys`: + +```typescript +import { Layer, Effect } from "effect"; +import * as Reactivity from "@effect/experimental/Reactivity"; +import { AppRuntime } from "..."; +import { APIService } from "..."; + +const MockApplicationLayer = Layer.mergeAll( + Layer.succeed(APIService, { + getMessages: () => Effect.succeed([ /* ...messages */ ]), + // ...other methods + }), + // ...other mocked layers +); + +const TestLayer = Layer.provideMerge(MockApplicationLayer, Reactivity.layer); + +it("integration-tests the full effect", () => { + renderWithRegistry(, [ + [AppRuntime.layer, TestLayer], + ]); + // ...assertions +}); +``` + +When any `AppRuntime.atom(...)` is read inside this registry, it builds the +runtime from your test layer instead of the production one. The layer is +built lazily into a scope tied to the registry's lifetime, so resources are +released automatically when the test unmounts. + +### Choosing Between `RegistryProvider` and `ScopedAtom` + +The patterns above use `RegistryProvider` to swap *values* in the registry +while keeping atom identities the same. For most isolated component tests +this is the right tool — it requires no changes to your atoms and the +existing helper APIs all keep working. + +[Scoped Atoms](/guides/atom-react/scoped-atoms) take a different approach: +they swap the atom *identity itself* per `Provider` boundary. Reach for them +when: + +- You need different *atoms* (not just different values) in different parts + of the same tree, for example for two independent panels each with their + own state. +- You want to parameterize an atom by tree position (URL params, a user id + passed from a route). +- You want to wholesale replace a derived atom in a test without seeding + every dependency it would otherwise read. + +For service mocking and most component tests, prefer `RegistryProvider` with +`initialValues`. Reach for `ScopedAtom` when atom identity itself needs to +vary across the tree — see the +[Scoped Atoms guide](/guides/atom-react/scoped-atoms) for the full story. diff --git a/docs/guides/atom-react/writables.md b/docs/guides/atom-react/writables.md new file mode 100644 index 00000000..ec2f7094 --- /dev/null +++ b/docs/guides/atom-react/writables.md @@ -0,0 +1,76 @@ +--- +title: "Writables & Modifying State" +parent: "Using Atom with React" +grand_parent: "Guides" +permalink: /guides/atom-react/writable-types +nav_order: 3 +--- + +# Writables & Modifying State + +Writable atoms are atoms that can be modified. They extend the general `Atom` +type with a write function. Writable atoms are typically created from +primitive values via `Atom.make(initialValue)`, or explicitly via +`Atom.writable(read, write)` for derived state. Effect-backed atoms aren't +writable by default since they're produced asynchronously by an effect, but +you can compose them into writable derivations when needed. + +Use [useAtom](/atom-react/Hooks.ts#useatom) when you need both the current +value and a setter, or [useAtomSet](/atom-react/Hooks.ts#useatomset) when you +only need a setter (the complement to `useAtomValue`). + +```typescript +import { Atom, useAtom } from "@effect-atom/atom-react"; + +export const nameAtom = Atom.make("John"); + +export function NameComponent() { + const [name, setName] = useAtom(nameAtom); + return
+ setName(e.target.value)} + /> +
; +} +``` + +Calling `setName` updates the atom and triggers a re-render for every +component subscribed to it. + +The setter also accepts a function form, which receives the current value — +useful for derived updates: + +```typescript +const [count, setCount] = useAtom(counterAtom); +// ... +setCount((current) => current + 1); +``` + +### Derived Writable Atoms + +Atoms created with `Atom.make((get) => ...)` are read-only by default. To +make a derived atom writable, use [Atom.writable](/atom/Atom.ts#writable), +which takes a read function and a write function: + +```typescript +import { Atom } from "@effect-atom/atom"; + +export const CountAtom = Atom.make(1); + +export const DoubleCountAtom = Atom.writable( + (get) => get(CountAtom) * 2, + (ctx, value: number) => ctx.set(CountAtom, value / 2) +); +``` + +`DoubleCountAtom` derives its value from `CountAtom`, and its write function +updates `CountAtom` so the derived value reflects the new state. This is the +recommended pattern — usually you want a write to propagate *upstream* to the +source of truth, not overwrite the derived state directly. + +It is possible to override the cached value of the derived atom itself via +[ctx.setSelf](/atom/Atom.ts#writecontext-interface), but this should be +avoided in almost all cases: if the parent atom later updates, the derived +atom will be recomputed from the read function, erasing the forced set. diff --git a/docs/guides/index.md b/docs/guides/index.md new file mode 100644 index 00000000..24065035 --- /dev/null +++ b/docs/guides/index.md @@ -0,0 +1,6 @@ +--- +title: "Guides" +permalink: /guides +nav_order: 2 +has_children: true +--- diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 00000000..caeb387a --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,6 @@ +--- +title: "Reference" +permalink: /reference +nav_order: 3 +has_children: true +--- diff --git a/scripts/docs-cp.js b/scripts/docs-cp.js index 136c0138..3aa4dbdb 100644 --- a/scripts/docs-cp.js +++ b/scripts/docs-cp.js @@ -31,7 +31,7 @@ function copyFiles(pkg) { const content = Fs.readFileSync(path, "utf8").replace( /^parent: Modules$/m, - `parent: "${name}"` + `parent: "${name}"\ngrand_parent: "Reference"` ); Fs.writeFileSync(destPath, content); } @@ -49,6 +49,7 @@ title: "${name}" has_children: true permalink: /docs/${pkg} nav_order: ${order} +parent: "Reference" --- `; @@ -59,5 +60,5 @@ packages().forEach((pkg, i) => { Fs.rmSync(Path.join("docs", pkg), { recursive: true, force: true }); Fs.mkdirSync(Path.join("docs", pkg), { recursive: true }); copyFiles(pkg); - generateIndex(pkg, i + 2); + generateIndex(pkg, i + 1); });