diff --git a/REACT.md b/REACT.md new file mode 100644 index 0000000..9be6418 --- /dev/null +++ b/REACT.md @@ -0,0 +1,288 @@ +# 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://github.com/Effect-TS/effect-smol/tree/main/packages/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`. 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 { Layer } from 'effect'; +import type { FirestoreService } from 'effect-firebase'; + +export const firestoreLayerAtom = Atom.make>( + Layer.empty as unknown as Layer.Layer, +); + +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 ( + + {children} + + ); +} +``` + +Wrap the layer in `useMemo` — the registry rebuilds the runtime whenever the +layer identity changes, so a fresh layer per render would tear down every +stream subscription on every render. + +## 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 +export const postByIdAtom = Atom.family((id: typeof PostId.Type) => + clientRuntime.atom(Effect.flatMap(PostRepository, (r) => r.getById(id))), +); + +// Live by id — keyed atom, one Stream per id +export const postByIdLiveAtom = Atom.family((id: typeof PostId.Type) => + clientRuntime.atom( + Stream.unwrap(Effect.map(PostRepository, (r) => r.getByIdStream(id))), + ), +); + +// 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); + }), +); + +export const deletePostAtom = clientRuntime.fn( + Effect.fnUntraced(function* (id: typeof PostId.Type) { + const r = yield* PostRepository; + yield* r.delete(id); + }), +); +``` + +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>`. +- `clientRuntime.fn(effectFn)` produces a writable atom whose value is the + `AsyncResult` of the last invocation, and whose setter runs the function. + +## 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(() => ) + .onFailure((cause) => ) + .onSuccess((posts) => + posts.length === 0 + ? + : <>{posts.map((p) => )}, + ) + .render(); +} +``` + +`AsyncResult` is `Initial | Success(value) | Failure(cause: Cause)`. +The `AsyncResult.builder` helper enforces exhaustive case handling at the type +level; alternatives like `AsyncResult.match` and a plain `_tag` switch are +also available. + +For keyed reads, call the family: + +```tsx +function PostView({ id }: { id: PostId }) { + 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 ( + + ); +} +``` + +`useAtomSet(atom, { mode: 'promise' })` returns `(arg) => Promise`. Modes: + +- `'value'` (default) — fire-and-forget; returns `void`. +- `'promise'` — await the result; rejects on failure. +- `'promiseExit'` — await an `Exit` 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 { 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, +}); + +function PostForm() { + const create = useAtomSet(addPostAtom, { mode: 'promise' }); + const form = useForm({ + defaultValues: { title: '', content: '' }, + validators: { onChange: Schema.toStandardSchemaV1(PostFormSchema) }, + onSubmit: async ({ value }) => { + await create({ ...value, /* fill required fields */ }); + form.reset(); + }, + }); + // render children with field.state.meta.errors[0]?.message +} +``` + +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` 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( + + undefined} /> + , + ); + 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. +- **Layer identity matters.** The runtime is rebuilt whenever the layer + identity changes in the registry. Always memoize the production layer. +- **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.** When the last subscriber to a Stream atom + unmounts, the stream is paused after an idle TTL (configurable on + `RegistryProvider`). On re-mount within the TTL, the cached value renders + immediately while a fresh stream spins up. +- **`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. diff --git a/example/app/package.json b/example/app/package.json index b51585b..7b7de98 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -9,26 +9,29 @@ }, "packageManager": "pnpm@10.25.0", "dependencies": { + "@effect/atom-react": "4.0.0-beta.70", + "@effect/platform-browser": "^4.0.0-beta.70", + "@nx/react": "22.4.5", + "@nx/vite": "22.5.4", + "@tanstack/react-form": "^1.32.0", + "@tanstack/react-router": "^1.139.3", + "@tanstack/react-router-devtools": "^1.139.3", + "@tanstack/router-plugin": "^1.139.3", + "@vitejs/plugin-react": "^4.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "effect": "^4.0.0-beta.70", - "@effect/platform-browser": "^4.0.0-beta.70", + "effect-firebase": "workspace:*", "firebase": "^12.8.0", - "tailwind-merge": "^3.4.0", "react": "19.2.4", - "@nx/react": "22.4.5", - "@tanstack/react-router": "^1.139.3", "react-dom": "19.2.4", - "@tanstack/react-router-devtools": "^1.139.3", - "vite": "7.1.8", - "@vitejs/plugin-react": "^4.2.0", - "@nx/vite": "22.5.4", - "@tanstack/router-plugin": "^1.139.3" + "tailwind-merge": "^3.4.0", + "vite": "7.1.8" }, "devDependencies": { "@effect-firebase/client": "workspace:*", + "@effect-firebase/mock": "workspace:*", "@example/shared": "workspace:*", - "effect-firebase": "workspace:*", "vite": "7.1.8" }, "peerDependencies": { diff --git a/example/app/src/__tests__/firestore.test.tsx b/example/app/src/__tests__/firestore.test.tsx new file mode 100644 index 0000000..84ef31a --- /dev/null +++ b/example/app/src/__tests__/firestore.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@testing-library/react'; +import { Stream } from 'effect'; +import { MockFirestoreService } from '@effect-firebase/mock'; +import { RegistryProvider } from '@effect/atom-react'; +import { describe, it, expect } from 'vitest'; +import { firestoreLayerAtom } from '../lib/atoms.js'; +import { PostList } from '../routes/firestore.js'; + +describe('PostList', () => { + it('renders the empty state when the mock layer yields no posts', async () => { + const layer = MockFirestoreService({ + streamQuery: () => Stream.make([]), + }); + + render( + + undefined} /> + , + ); + + expect(await screen.findByText(/No posts found/i)).toBeTruthy(); + }); +}); diff --git a/example/app/src/app/app.tsx b/example/app/src/app/app.tsx index 4a3c286..0498c2c 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -1,39 +1,54 @@ +import { useMemo } from 'react'; import { initializeApp } from 'firebase/app'; import { getFunctions, connectFunctionsEmulator } from 'firebase/functions'; import { connectFirestoreEmulator, initializeFirestore, } from 'firebase/firestore'; +import { Client } from '@effect-firebase/client'; +import { RegistryProvider } from '@effect/atom-react'; import SideMenu from '../components/menu/side-menu.js'; import MenuItem from '../components/menu/menu-item.js'; +import { firestoreLayerAtom } from '../lib/atoms.js'; interface AppProps { children: React.ReactNode; } export function App({ children }: AppProps) { - const app = initializeApp({ - projectId: 'effect-firebase-example', - }); - const functions = getFunctions(app, 'europe-north1'); - connectFunctionsEmulator(functions, 'localhost', 5001); + const layer = useMemo(() => { + const app = initializeApp({ projectId: 'effect-firebase-example' }); + const functions = getFunctions(app, 'europe-north1'); + connectFunctionsEmulator(functions, 'localhost', 5001); - const db = initializeFirestore(app, { ignoreUndefinedProperties: true }); - connectFirestoreEmulator(db, 'localhost', 8080); + const firestore = initializeFirestore(app, { + ignoreUndefinedProperties: true, + }); + connectFirestoreEmulator(firestore, 'localhost', 8080); + + return Client.layer({ firestore }); + }, []); + + const initialValues = useMemo( + () => [[firestoreLayerAtom, layer] as const] as const, + [layer], + ); return ( -
- - - - - - - {/* Main content area */} -
-
{children}
-
-
+ +
+ + + + + + + {/* Main content area */} +
+
{children}
+
+
+
); } diff --git a/example/app/src/lib/atoms.ts b/example/app/src/lib/atoms.ts new file mode 100644 index 0000000..65708a3 --- /dev/null +++ b/example/app/src/lib/atoms.ts @@ -0,0 +1,82 @@ +import { Effect, Layer, Option, Stream } from 'effect'; +import { Atom } from 'effect/unstable/reactivity'; +import type { AtomContext } from 'effect/unstable/reactivity/Atom'; +import type { FirestoreService } from 'effect-firebase'; +import { Query } from 'effect-firebase'; +import { + PostId, + PostModel, + PostRepository, +} from '@example/shared'; + +/** + * Indirection that makes the Firestore layer swappable at the registry level. + * + * Production: seeded by `` + * Tests: seed with `MockFirestoreService(overrides)` from `@effect-firebase/mock`. + * + * Default value is an empty layer; any atom that depends on the runtime will + * fail loudly if the provider doesn't seed it. + */ +export const firestoreLayerAtom = Atom.make>( + Layer.empty as unknown as Layer.Layer, +); + +/** + * Runtime atom — rebuilds whenever `firestoreLayerAtom` changes in the + * registry. All Effect/Stream atoms in this app are scoped to this runtime + * (and so receive `FirestoreService` automatically). + */ +export const clientRuntime = Atom.runtime((get: AtomContext) => + get(firestoreLayerAtom), +); + +const repoEffect = PostRepository; + +// One-shot read by id +export const postByIdAtom = Atom.family((id: typeof PostId.Type) => + clientRuntime.atom( + Effect.flatMap(repoEffect, (r) => r.getById(id)), + ), +); + +// Live read by id +export const postByIdLiveAtom = Atom.family((id: typeof PostId.Type) => + clientRuntime.atom( + Stream.unwrap(Effect.map(repoEffect, (r) => r.getByIdStream(id))), + ), +); + +// Live list of latest posts. A single canonical atom (no family) so every +// subscriber shares one Firestore subscription. +export const latestPostsAtom = clientRuntime.atom( + Stream.unwrap(Effect.map(repoEffect, (r) => r.latestPosts())), +); + +// Mutations — writable atoms exposing AsyncResult state and a setter. +export const addPostAtom = clientRuntime.fn( + Effect.fnUntraced(function* (data: typeof PostModel.insert.Type) { + const r = yield* repoEffect; + return yield* r.add(data); + }), +); + +export const updatePostAtom = clientRuntime.fn( + Effect.fnUntraced(function* (input: { + readonly id: typeof PostId.Type; + readonly data: Partial>; + }) { + const r = yield* repoEffect; + yield* r.update(input.id, input.data); + }), +); + +export const deletePostAtom = clientRuntime.fn( + Effect.fnUntraced(function* (id: typeof PostId.Type) { + const r = yield* repoEffect; + yield* r.delete(id); + }), +); + +// Re-export Option for consumers that need to discriminate getById results. +export { Option, Query }; diff --git a/example/app/src/routes/firestore.tsx b/example/app/src/routes/firestore.tsx index f683f79..783fbbd 100644 --- a/example/app/src/routes/firestore.tsx +++ b/example/app/src/routes/firestore.tsx @@ -1,9 +1,10 @@ -import { PostModel, PostRepository, PostId, AuthorId } from '@example/shared'; -import { Firestore } from '@effect-firebase/client'; -import { getApp } from 'firebase/app'; +import { useState } from 'react'; import { createFileRoute } from '@tanstack/react-router'; -import { Effect, Option, Schema, Stream, Fiber, DateTime } from 'effect'; -import { useEffect, useState } from 'react'; +import { Cause, DateTime, Option, Schema } from 'effect'; +import { AsyncResult } from 'effect/unstable/reactivity'; +import { useForm } from '@tanstack/react-form'; +import { useAtomValue, useAtomSet } from '@effect/atom-react'; +import { PostId, PostModel, AuthorId } from '@example/shared'; import { Button, Card, @@ -14,97 +15,63 @@ import { Spinner, TextArea, } from '../components/core'; +import { + latestPostsAtom, + addPostAtom, + updatePostAtom, + deletePostAtom, +} from '../lib/atoms.js'; export const Route = createFileRoute('/firestore')({ component: RouteComponent, }); type Post = typeof PostModel.Type; +type EditingPost = { + readonly id: typeof PostId.Type; + readonly title: string; + readonly content: string; +}; + +const PostFormSchema = Schema.Struct({ + title: Schema.NonEmptyString, + content: Schema.NonEmptyString, +}); -const formatDateTime = (date: DateTime.DateTime) => { - return DateTime.formatLocal(date, { +const formatDateTime = (date: DateTime.DateTime) => + DateTime.formatLocal(date, { year: 'numeric', month: 'long', day: 'numeric', hour: '2-digit', minute: '2-digit', }); -}; - -function RouteComponent() { - const [posts, setPosts] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Form state - const [title, setTitle] = useState(''); - const [content, setContent] = useState(''); - const [submitting, setSubmitting] = useState(false); - const [editingId, setEditingId] = useState(null); - - // Repository instance state - const [repo, setRepo] = useState | null>(null); - - useEffect(() => { - // Initialize repository - const makeRepo = PostRepository.pipe( - Effect.provide(Firestore.layerFromApp(getApp())) - ); - - Effect.runPromise(makeRepo) - .then((r) => setRepo(r)) - .catch((err) => { - console.error('Failed to create repository:', err); - setError('Failed to initialize repository'); - }); - }, []); - - useEffect(() => { - if (!repo) return; - - // Subscribe to posts using Effect Stream - const program = Stream.runForEach(repo.latestPosts(), (postsArray) => - Effect.sync(() => { - setPosts([...postsArray]); - setLoading(false); - }) - ).pipe( - Effect.catch((err) => - Effect.sync(() => { - console.error('Error streaming posts:', err); - setError(String(err)); - setLoading(false); - }) - ) - ); - - // Run the stream and get the fiber for cleanup - const fiber = Effect.runFork(program); - - // Cleanup: interrupt the stream when component unmounts - return () => { - Effect.runFork(Fiber.interrupt(fiber)); - }; - }, [repo]); - const handleCreate = async () => { - if (!repo || !title || !content) return; +function PostForm({ + editing, + onDone, +}: { + editing: EditingPost | null; + onDone: () => void; +}) { + const create = useAtomSet(addPostAtom, { mode: 'promise' }); + const update = useAtomSet(updatePostAtom, { mode: 'promise' }); - setSubmitting(true); - try { - if (editingId) { - const updateEffect = repo.update(PostId.make(editingId), { - title, - content, + const form = useForm({ + defaultValues: editing + ? { title: editing.title, content: editing.content } + : { title: '', content: '' }, + validators: { onChange: Schema.toStandardSchemaV1(PostFormSchema) }, + onSubmit: async ({ value }) => { + if (editing) { + await update({ + id: editing.id, + data: { title: value.title, content: value.content }, }); - await Effect.runPromise(updateEffect as Effect.Effect); - setEditingId(null); } else { - const createEffect = repo.add({ - title, - content, + await create({ + title: value.title, + content: value.content, author: AuthorId.make('1'), createdAt: undefined, updatedAt: undefined, @@ -112,108 +79,110 @@ function RouteComponent() { optional: Option.none(), list: [], }); - await Effect.runPromise(createEffect).catch((err) => { - console.error('Failed to create post:', err); - setError('Failed to create post'); - }); } - setTitle(''); - setContent(''); - } catch (err) { - console.error('Failed to save post:', err); - setError('Failed to save post'); - } finally { - setSubmitting(false); - } - }; - - const handleEdit = (post: Post) => { - setEditingId(post.id); - setTitle(post.title); - setContent(post.content); - // Scroll to top - window.scrollTo({ top: 0, behavior: 'smooth' }); - }; - - const handleCancelEdit = () => { - setEditingId(null); - setTitle(''); - setContent(''); - }; - - const handleDelete = async (id: string) => { - if (!repo) return; - - try { - const deleteEffect = repo.delete(id as Schema.Schema.Type); - await Effect.runPromise(deleteEffect as Effect.Effect); - } catch (err) { - console.error('Failed to delete post:', err); - setError('Failed to delete post'); - } - }; + form.reset(); + onDone(); + }, + }); return ( -
-
-

- Firestore CRUD -

-

- Manage posts using the shared Effect repository and Firestore -

-
- - {/* Create Post Form */} - - {editingId ? 'Edit Post' : 'Create New Post'} - - setTitle(e.target.value)} - /> -