-
Notifications
You must be signed in to change notification settings - Fork 2
docs: React patterns with @effect/atom-react + example app reference implementation #64
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
83334e2
docs: add React patterns for repositories, forms, and tests
claude 30d85ef
refactor(react): switch React patterns to @effect/atom-react
claude 9dca9cd
docs(react): add honest comparison to TanStack Query
claude 4086c4c
Merge remote-tracking branch 'origin/main' into claude/vigilant-pasca…
claude d6e53ee
fix(example): harden the atom-based React reference implementation
claude 5195cef
docs(react): correct API claims and align samples with the fixed code
claude 1ac0d99
docs(react): drop the TanStack Query comparison section
claude c182cca
fix(example): classify runtime deps correctly in app package.json
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,342 @@ | ||
| # React patterns for `effect-firebase` | ||
|
|
||
| This guide shows how to use `effect-firebase` repositories from a React app: | ||
| fetching, subscribing to live updates, mutations, validated forms, and tests. | ||
| It documents reference code in [`example/app`](./example/app) — copy what fits, | ||
| adapt the rest. | ||
|
|
||
| The patterns are built on | ||
| [`@effect/atom-react`](https://www.npmjs.com/package/@effect/atom-react) | ||
| (official Effect-TS React binding) and `effect`'s built-in | ||
| `unstable/reactivity/Atom` module. Both are part of Effect v4 and ship in | ||
| lockstep — the `react` binding peer-depends on the exact Effect beta it was | ||
| released against. | ||
|
|
||
| ## Contents | ||
|
|
||
| 1. [Runtime setup](#1-runtime-setup) | ||
| 2. [Repository atoms](#2-repository-atoms) | ||
| 3. [Reading data](#3-reading-data) | ||
| 4. [Mutations](#4-mutations) | ||
| 5. [Forms with validation](#5-forms-with-validation) | ||
| 6. [Testing with a mock layer](#6-testing-with-a-mock-layer) | ||
| 7. [Caveats](#7-caveats) | ||
|
|
||
| --- | ||
|
|
||
| ## 1. Runtime setup | ||
|
|
||
| The runtime is composed from two atoms: | ||
|
|
||
| - A **layer atom** that holds the `Layer<FirestoreService>`. This is the test | ||
| seam — production code seeds it via `RegistryProvider.initialValues`; tests | ||
| override it with a mock layer. | ||
| - A **runtime atom** built from the layer atom via `Atom.runtime((get) => get(layerAtom))`. | ||
| All repository atoms are created via `runtime.atom(...)` / `runtime.fn(...)` | ||
| so they receive `FirestoreService` from the configured layer. | ||
|
|
||
| ```ts | ||
| // example/app/src/lib/atoms.ts | ||
| import { Atom } from 'effect/unstable/reactivity'; | ||
| import { Effect, Layer } from 'effect'; | ||
| import { FirestoreService } from 'effect-firebase'; | ||
|
|
||
| // Atom.keepAlive is required: the registry garbage-collects non-keepAlive | ||
| // atoms with no subscribers, so the seeded layer would be dropped moments | ||
| // after mount whenever the first route reads no atoms. | ||
| export const firestoreLayerAtom = Atom.keepAlive( | ||
| Atom.make<Layer.Layer<FirestoreService>>( | ||
| // The default dies with an actionable message on first use, so a | ||
| // forgotten seed fails loudly instead of being silenced by a cast. | ||
| Layer.effect( | ||
| FirestoreService, | ||
| Effect.die('firestoreLayerAtom must be seeded via RegistryProvider initialValues'), | ||
| ), | ||
| ), | ||
| ); | ||
|
|
||
| export const clientRuntime = Atom.runtime((get) => get(firestoreLayerAtom)); | ||
| ``` | ||
|
|
||
| At app root, wrap the tree in `RegistryProvider` and seed the layer atom: | ||
|
|
||
| ```tsx | ||
| // example/app/src/app/app.tsx | ||
| import { RegistryProvider } from '@effect/atom-react'; | ||
| import { Client } from '@effect-firebase/client'; | ||
| import { firestoreLayerAtom } from '../lib/atoms.js'; | ||
|
|
||
| export function App({ children }) { | ||
| const layer = useMemo(() => { | ||
| const firestore = initializeFirestore(initializeApp({...}), {...}); | ||
| connectFirestoreEmulator(firestore, 'localhost', 8080); | ||
| return Client.layer({ firestore }); | ||
| }, []); | ||
|
|
||
| const initialValues = useMemo( | ||
| () => [[firestoreLayerAtom, layer] as const] as const, | ||
| [layer], | ||
| ); | ||
|
|
||
| return ( | ||
| <RegistryProvider initialValues={initialValues}> | ||
| {children} | ||
| </RegistryProvider> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| Wrap the layer in `useMemo` so Firebase initialization doesn't re-run on | ||
| every render. Note that `RegistryProvider` reads `initialValues` only when | ||
| the registry is first created — changing the array (or the layer's identity) | ||
| on a later render is silently ignored. To swap the layer at runtime, set the | ||
| atom's value in the registry instead — `registry.set(firestoreLayerAtom, newLayer)` | ||
| or the setter from `useAtomSet(firestoreLayerAtom)`. The runtime atom rebuilds | ||
| (tearing down every subscription) whenever the layer atom's **value** changes | ||
| in the registry. | ||
|
|
||
| ## 2. Repository atoms | ||
|
|
||
| For each repository, define atoms once at module scope. Atom identity is | ||
| stable across renders and subscribers, so the same `latestPostsAtom` shared | ||
| across components opens a single Firestore subscription. | ||
|
|
||
| ```ts | ||
| // example/app/src/lib/atoms.ts | ||
| import { Effect, Stream } from 'effect'; | ||
| import { Atom } from 'effect/unstable/reactivity'; | ||
| import { PostId, PostRepository, PostModel } from '@example/shared'; | ||
|
|
||
| // One-shot by id — keyed atom, one Effect per id. `withReactivity` re-runs | ||
| // the read whenever a mutation declaring the same reactivity key completes. | ||
| export const postByIdAtom = Atom.family((id: typeof PostId.Type) => | ||
| clientRuntime | ||
| .atom(Effect.flatMap(PostRepository, (r) => r.getById(id))) | ||
| .pipe(Atom.withReactivity(['posts'])), | ||
| ); | ||
|
|
||
| // Live by id — keyed atom, one Stream per id. The idle TTL keeps a per-id | ||
| // listener alive briefly after its last subscriber unmounts (cheap | ||
| // back-navigation) without leaking one listener per visited post. | ||
| export const postByIdLiveAtom = Atom.family((id: typeof PostId.Type) => | ||
| clientRuntime | ||
| .atom( | ||
| Stream.unwrap(Effect.map(PostRepository, (r) => r.getByIdStream(id))), | ||
| ) | ||
| .pipe(Atom.setIdleTTL('30 seconds')), | ||
| ); | ||
|
|
||
| // Live list — single shared atom (no family) | ||
| export const latestPostsAtom = clientRuntime.atom( | ||
| Stream.unwrap(Effect.map(PostRepository, (r) => r.latestPosts())), | ||
| ); | ||
|
|
||
| // Mutations — writable atoms with AsyncResult state and a setter | ||
| export const addPostAtom = clientRuntime.fn( | ||
| Effect.fnUntraced(function* (data: typeof PostModel.insert.Type) { | ||
| const r = yield* PostRepository; | ||
| return yield* r.add(data); | ||
| }), | ||
| { concurrent: true, reactivityKeys: ['posts'] }, | ||
| ); | ||
|
|
||
| export const deletePostAtom = clientRuntime.fn( | ||
| Effect.fnUntraced(function* (id: typeof PostId.Type) { | ||
| const r = yield* PostRepository; | ||
| yield* r.delete(id); | ||
| }), | ||
| { concurrent: true, reactivityKeys: ['posts'] }, | ||
| ); | ||
| ``` | ||
|
|
||
| Notes: | ||
|
|
||
| - `Atom.family((arg) => atom)` returns a function that memoizes atoms by `arg` | ||
| (using `Equal`-based equality). `postByIdLiveAtom(postId)` returns the same | ||
| atom instance each time, so multiple components subscribed to the same id | ||
| share one Stream. | ||
| - `clientRuntime.atom(effect)` and `clientRuntime.atom(stream)` are | ||
| overloaded; both produce an `Atom<AsyncResult<A, E>>`. | ||
| - `clientRuntime.fn(effectFn)` produces a writable atom whose value is the | ||
| `AsyncResult` of the last invocation, and whose setter runs the function. | ||
| - **Invalidation:** live (stream) atoms need none — the Firestore snapshot | ||
| pushes updates. One-shot reads pair `Atom.withReactivity(keys)` on the read | ||
| side with `reactivityKeys` on mutations: a completed mutation re-runs every | ||
| read that shares a key. | ||
| - **Concurrency:** without `concurrent: true`, a second invocation of an fn | ||
| atom *interrupts* the in-flight previous one (latest-wins) and all pending | ||
| promise-mode awaiters resolve with the last invocation's result. That | ||
| default suits search-as-you-type reads; for mutations it can silently drop | ||
| a write, so pass `concurrent: true`. | ||
|
|
||
| ## 3. Reading data | ||
|
|
||
| ```tsx | ||
| import { AsyncResult } from 'effect/unstable/reactivity'; | ||
| import { useAtomValue } from '@effect/atom-react'; | ||
| import { Cause } from 'effect'; | ||
| import { latestPostsAtom } from '../lib/atoms.js'; | ||
|
|
||
| function PostList() { | ||
| const result = useAtomValue(latestPostsAtom); | ||
|
|
||
| return AsyncResult.builder(result) | ||
| .onInitial(() => <Spinner />) | ||
| .onFailure((cause) => <Error message={Cause.pretty(cause)} />) | ||
| .onSuccess((posts) => | ||
| posts.length === 0 | ||
| ? <Empty /> | ||
| : <>{posts.map((p) => <PostCard key={p.id} post={p} />)}</>, | ||
| ) | ||
| .exhaustive(); | ||
| } | ||
| ``` | ||
|
|
||
| `AsyncResult<A, E>` is `Initial | Success(value) | Failure(cause: Cause<E>)`. | ||
| The `AsyncResult.builder` helper tracks handled cases at the type level: | ||
| `.exhaustive()` only becomes available once every case is handled, while | ||
| `.render()` is lenient — it compiles with handlers missing, renders `null` | ||
| for an unhandled initial/success and **rethrows** an unhandled failure at | ||
| runtime. Prefer `.exhaustive()`. Alternatives like `AsyncResult.match` and a | ||
| plain `_tag` switch are also available. | ||
|
|
||
| For keyed reads, call the family: | ||
|
|
||
| ```tsx | ||
| function PostView({ id }: { id: typeof PostId.Type }) { | ||
| const result = useAtomValue(postByIdLiveAtom(id)); | ||
| // ... | ||
| } | ||
| ``` | ||
|
|
||
| ## 4. Mutations | ||
|
|
||
| ```tsx | ||
| import { useAtomSet } from '@effect/atom-react'; | ||
| import { addPostAtom, deletePostAtom } from '../lib/atoms.js'; | ||
|
|
||
| function CreatePost() { | ||
| const create = useAtomSet(addPostAtom, { mode: 'promise' }); | ||
| return ( | ||
| <Button | ||
| onClick={() => create({ title: 'Hello', /* ... */ }).catch(/* ... */)} | ||
| > | ||
| Create | ||
| </Button> | ||
| ); | ||
| } | ||
| ``` | ||
|
|
||
| `useAtomSet(atom, { mode: 'promise' })` returns `(arg) => Promise<A>`. Modes: | ||
|
|
||
| - `'value'` (default) — fire-and-forget; returns `void`. | ||
| - `'promise'` — await the result; rejects on failure. | ||
| - `'promiseExit'` — await an `Exit<A, E>` instead of throwing. | ||
|
|
||
| If you also need the `AsyncResult` state (loading / success / error) for UI, | ||
| use `useAtom(atom)` to get both `[result, set]`. | ||
|
|
||
| ## 5. Forms with validation | ||
|
|
||
| `effect/Schema` implements | ||
| [Standard Schema v1](https://github.com/standard-schema/standard-schema), and | ||
| [`@tanstack/react-form`](https://tanstack.com/form/latest) accepts a Standard | ||
| Schema validator directly. Wrap your schema with `Schema.toStandardSchemaV1` | ||
| and pass it to `validators.onChange`: | ||
|
|
||
| ```tsx | ||
| import { useState } from 'react'; | ||
| import { Schema } from 'effect'; | ||
| import { useForm } from '@tanstack/react-form'; | ||
| import { useAtomSet } from '@effect/atom-react'; | ||
|
|
||
| const PostFormSchema = Schema.Struct({ | ||
| title: Schema.NonEmptyString, | ||
| content: Schema.NonEmptyString, | ||
| }); | ||
|
|
||
| const postFormValidator = Schema.toStandardSchemaV1(PostFormSchema); | ||
|
|
||
| function PostForm() { | ||
| const create = useAtomSet(addPostAtom, { mode: 'promise' }); | ||
| const [submitError, setSubmitError] = useState<string | null>(null); | ||
| const form = useForm({ | ||
| defaultValues: { title: '', content: '' }, | ||
| validators: { onChange: postFormValidator }, | ||
| onSubmit: async ({ value }) => { | ||
| setSubmitError(null); | ||
| try { | ||
| await create({ ...value, /* fill required fields */ }); | ||
| form.reset(); | ||
| } catch { | ||
| // form-core rethrows onSubmit errors out of handleSubmit, so an | ||
| // unhandled failure here becomes an unhandled promise rejection | ||
| // with no user-visible feedback. | ||
| setSubmitError('Failed to save post'); | ||
| } | ||
| }, | ||
| }); | ||
| // render <form.Field> children with field.state.meta.errors[0]?.message, | ||
| // render submitError, and submit with `void form.handleSubmit()` | ||
| } | ||
| ``` | ||
|
|
||
| See [`example/app/src/routes/firestore.tsx`](./example/app/src/routes/firestore.tsx) | ||
| for the full form including edit mode (re-key the form on the editing id to | ||
| load fresh defaults). | ||
|
|
||
| ## 6. Testing with a mock layer | ||
|
|
||
| `@effect-firebase/mock` exports `MockFirestoreService(overrides)`, which | ||
| returns a `Layer<FirestoreService>` whose methods throw by default but accept | ||
| per-method overrides. Pass it as the value for `firestoreLayerAtom` in | ||
| `RegistryProvider.initialValues`: | ||
|
|
||
| ```tsx | ||
| // example/app/src/__tests__/firestore.test.tsx | ||
| import { render, screen } from '@testing-library/react'; | ||
| import { Stream } from 'effect'; | ||
| import { MockFirestoreService } from '@effect-firebase/mock'; | ||
| import { RegistryProvider } from '@effect/atom-react'; | ||
| import { firestoreLayerAtom } from '../lib/atoms.js'; | ||
| import { PostList } from '../routes/firestore.js'; | ||
|
|
||
| it('renders the empty state when no posts exist', async () => { | ||
| const layer = MockFirestoreService({ | ||
| streamQuery: () => Stream.make([]), | ||
| }); | ||
| render( | ||
| <RegistryProvider initialValues={[[firestoreLayerAtom, layer] as const]}> | ||
| <PostList onEdit={() => undefined} /> | ||
| </RegistryProvider>, | ||
| ); | ||
| expect(await screen.findByText(/No posts found/i)).toBeTruthy(); | ||
| }); | ||
| ``` | ||
|
|
||
| The components under test never change between production and test — only the | ||
| layer at the registry boundary differs. Vitest needs `environment: 'jsdom'`; | ||
| see the `test` block in [`example/app/vite.config.ts`](./example/app/vite.config.ts). | ||
|
|
||
| ## 7. Caveats | ||
|
|
||
| - **`@effect/atom-react` is lockstep with `effect` betas.** Each release of | ||
| `@effect/atom-react@4.0.0-beta.N` peer-depends on `effect@^4.0.0-beta.N`. Bump | ||
| them together. | ||
| - **The layer atom's value drives the runtime.** The runtime is rebuilt — | ||
| tearing down every subscription — whenever the layer atom's value changes | ||
| in the registry (`registry.set` / `useAtomSet`). `initialValues` is read | ||
| only at registry creation, so it can't be used to swap the layer later. | ||
| - **Atom families key by `Equal` equality.** Branded ids work out of the box; | ||
| object keys need to be either `Equal`-implementing classes or pre-serialized | ||
| to a stable string before passing to a family. | ||
| - **Subscriptions are refcounted, and disposal is immediate by default.** | ||
| When the last subscriber of an atom unmounts, the registry disposes the | ||
| atom — and its stream — right away. To keep an atom warm across remounts, | ||
| opt in per atom with `Atom.setIdleTTL('30 seconds')` or `Atom.keepAlive`, | ||
| or set a registry-wide `defaultIdleTTL` on `RegistryProvider`. During a TTL | ||
| window the subscription stays live (not paused), and a remount reattaches | ||
| to it; after the TTL nothing is cached. | ||
| - **`unstable/reactivity` is unstable.** The Atom module lives in Effect's | ||
| `unstable/` namespace until v4 stable. Treat API churn between betas as | ||
| possible — pin tightly and update intentionally. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.