diff --git a/REACT.md b/REACT.md new file mode 100644 index 00000000..d2dd8206 --- /dev/null +++ b/REACT.md @@ -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`. 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>( + // 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 ( + + {children} + + ); +} +``` + +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>`. +- `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(() => ) + .onFailure((cause) => ) + .onSuccess((posts) => + posts.length === 0 + ? + : <>{posts.map((p) => )}, + ) + .exhaustive(); +} +``` + +`AsyncResult` is `Initial | Success(value) | Failure(cause: Cause)`. +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 ( + + ); +} +``` + +`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 { 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(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 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` 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. +- **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. diff --git a/README.md b/README.md index d2c56164..77ae78d7 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,11 @@ Firebase integration for [Effect](https://effect.website). Provides schemas, mod | [@effect-firebase/client](./packages/client) | Firebase Client SDK | | [@effect-firebase/mock](./packages/mock) | In-memory mock for testing | +## Guides + +- [React patterns](./REACT.md) — atoms, live queries, mutations, forms, and testing from React +- [Migration guide](./MIGRATION.md) — upgrading from earlier versions + ## Installation ```bash diff --git a/example/app/package.json b/example/app/package.json index ff5f4427..2a3e2aa9 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -9,30 +9,28 @@ }, "packageManager": "pnpm@10.25.0", "dependencies": { + "@effect-firebase/client": "workspace:*", + "@effect/atom-react": "catalog:", + "@effect/platform-browser": "catalog:", + "@example/shared": "workspace:*", + "@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": "catalog:", - "@effect/platform-browser": "catalog:", + "effect-firebase": "workspace:*", "firebase": "catalog:", - "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" }, "devDependencies": { - "@effect-firebase/client": "workspace:*", - "@example/shared": "workspace:*", - "effect-firebase": "workspace:*", + "@effect-firebase/mock": "workspace:*", "vite": "7.1.8" - }, - "peerDependencies": { - "@effect-firebase/client": "workspace:*", - "@example/shared": "workspace:*" } } diff --git a/example/app/src/__tests__/firestore.test.tsx b/example/app/src/__tests__/firestore.test.tsx new file mode 100644 index 00000000..84ef31ae --- /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 4a3c2868..c7dd7c70 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -1,39 +1,51 @@ +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 }); + }, []); + + // RegistryProvider reads initialValues only when the registry is first + // created, so the array doesn't need a stable identity. 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 00000000..8a3179d6 --- /dev/null +++ b/example/app/src/lib/atoms.ts @@ -0,0 +1,99 @@ +import { Effect, Layer, Stream } from 'effect'; +import { Atom } from 'effect/unstable/reactivity'; +import { FirestoreService } 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`. + * + * `Atom.keepAlive` is required: the registry garbage-collects non-keepAlive + * atoms that have no subscribers, so a seeded value would be dropped moments + * after mount whenever the first route reads no atoms — later reads would + * silently fall back to the default below. + * + * The default layer dies with an actionable message on first use, so a + * forgotten seed fails with a pointer to the fix instead of being silenced + * by a type cast. + */ +export const firestoreLayerAtom = Atom.keepAlive( + Atom.make>( + Layer.effect( + FirestoreService, + Effect.die( + 'firestoreLayerAtom must be seeded via ', + ), + ), + ), +); + +/** + * Runtime atom — rebuilds whenever `firestoreLayerAtom` changes in the + * registry (via `registry.set` / `useAtomSet`; `initialValues` is only read + * when the registry is created). All Effect/Stream atoms in this app are + * scoped to this runtime (and so receive `FirestoreService` automatically). + */ +export const clientRuntime = Atom.runtime((get) => get(firestoreLayerAtom)); + +// The atoms below double as the reference implementation for REACT.md §2–§4; +// postByIdAtom / postByIdLiveAtom are not used by the app itself. + +// One-shot read by id. `withReactivity` re-runs the read whenever a mutation +// declaring the same key completes; the live atoms below don't need it +// because the Firestore snapshot stream already pushes updates. +export const postByIdAtom = Atom.family((id: typeof PostId.Type) => + clientRuntime + .atom(Effect.flatMap(PostRepository, (r) => r.getById(id))) + .pipe(Atom.withReactivity(['posts'])), +); + +// Live read by id. The idle TTL lets a per-id listener linger briefly after +// its last subscriber unmounts (cheap back-navigation) without leaking one +// listener per visited post for the life of the app. +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 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(PostRepository, (r) => r.latestPosts())), +); + +// Mutations — writable atoms exposing AsyncResult state and a setter. +// `concurrent: true` lets invocations overlap; the default interrupts the +// in-flight previous call (latest-wins), which can drop a write when two +// fire in quick succession. `reactivityKeys` refreshes the one-shot reads +// above after each completed mutation. +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 updatePostAtom = clientRuntime.fn( + Effect.fnUntraced(function* (input: { + readonly id: typeof PostId.Type; + readonly data: Partial>; + }) { + const r = yield* PostRepository; + yield* r.update(input.id, input.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'] }, +); diff --git a/example/app/src/routes/firestore.tsx b/example/app/src/routes/firestore.tsx index f683f79f..06f12fb5 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 { PostModel, AuthorId } from '@example/shared'; import { Button, Card, @@ -14,144 +15,261 @@ 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 = Pick; -const formatDateTime = (date: DateTime.DateTime) => { - return 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'); - }); - }, []); +const PostFormSchema = Schema.Struct({ + title: Schema.NonEmptyString, + content: Schema.NonEmptyString, +}); - useEffect(() => { - if (!repo) return; +const postFormValidator = Schema.toStandardSchemaV1(PostFormSchema); - // 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); - }) - ) - ); +const dateTimeFormat = new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', +}); - // Run the stream and get the fiber for cleanup - const fiber = Effect.runFork(program); +const formatDateTime = (date: DateTime.DateTime) => + DateTime.formatIntl(date, dateTimeFormat); - // Cleanup: interrupt the stream when component unmounts - return () => { - Effect.runFork(Fiber.interrupt(fiber)); +const fieldError = (field: { + readonly state: { + readonly meta: { + readonly isTouched: boolean; + readonly errors: ReadonlyArray< + { readonly message?: string } | undefined + >; }; - }, [repo]); + }; +}) => + field.state.meta.isTouched + ? field.state.meta.errors[0]?.message + : undefined; - 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' }); + const [submitError, setSubmitError] = useState(null); - setSubmitting(true); - try { - if (editingId) { - const updateEffect = repo.update(PostId.make(editingId), { - title, - content, - }); - await Effect.runPromise(updateEffect as Effect.Effect); - setEditingId(null); - } else { - const createEffect = repo.add({ - title, - content, - author: AuthorId.make('1'), - createdAt: undefined, - updatedAt: undefined, - checked: false, - optional: Option.none(), - list: [], - }); - await Effect.runPromise(createEffect).catch((err) => { - console.error('Failed to create post:', err); - setError('Failed to create post'); - }); + const form = useForm({ + defaultValues: editing + ? { title: editing.title, content: editing.content } + : { title: '', content: '' }, + validators: { onChange: postFormValidator }, + onSubmit: async ({ value }) => { + setSubmitError(null); + try { + if (editing) { + await update({ + id: editing.id, + data: { title: value.title, content: value.content }, + }); + } else { + await create({ + title: value.title, + content: value.content, + author: AuthorId.make('1'), + createdAt: undefined, + updatedAt: undefined, + checked: false, + optional: Option.none(), + list: [], + }); + } + form.reset(); + onDone(); + } catch { + // form-core rethrows onSubmit errors out of handleSubmit, so an + // unhandled failure here would become an unhandled rejection with + // no user-visible feedback. + setSubmitError('Failed to save 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' }); - }; + return ( + + {editing ? 'Edit Post' : 'Create New Post'} + +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > + + {(field) => ( + field.handleChange(e.target.value)} + error={fieldError(field)} + onBlur={field.handleBlur} + /> + )} + + + {(field) => ( +