From 83334e28882cbd6d73c574622e54747ffe8fac4a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 19:50:01 +0000 Subject: [PATCH 1/2] docs: add React patterns for repositories, forms, and tests Introduce REACT.md and reference implementation in example/app for using effect-firebase repositories from React: a RuntimeProvider that binds a Firestore Layer to a ManagedRuntime, plus useEffectQuery, useEffectStream, and useEffectMutation hooks that surface a Result- shaped state and handle fiber cleanup. Refactor the /firestore route to use the hooks together with @tanstack/react-form and effect/Schema (via toStandardSchemaV1) for validated CRUD. Add a Vitest setup with @testing-library/react and a demo test that swaps in @effect-firebase/mock at the provider boundary. The hook surface intentionally mirrors @effect-atom/atom-react idioms so a future migration is mechanical once atom-react supports Effect v4. Also remove a duplicate deleteRecursive key in the mock service that blocked the package build. https://claude.ai/code/session_01R1D97BwwWVdGzARJY8iWeB --- REACT.md | 288 +++++++++++++ example/app/package.json | 2 + example/app/src/__tests__/firestore.test.tsx | 22 + example/app/src/app/app.tsx | 45 +- example/app/src/lib/effect-react.tsx | 152 +++++++ example/app/src/routes/firestore.tsx | 414 ++++++++++--------- example/app/tsconfig.app.json | 3 + example/app/tsconfig.spec.json | 12 +- example/app/vite.config.ts | 10 +- pnpm-lock.yaml | 64 +++ 10 files changed, 785 insertions(+), 227 deletions(-) create mode 100644 REACT.md create mode 100644 example/app/src/__tests__/firestore.test.tsx create mode 100644 example/app/src/lib/effect-react.tsx diff --git a/REACT.md b/REACT.md new file mode 100644 index 0000000..669b216 --- /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. + +> **Status: interim.** Once +> [`@effect-atom/atom-react`](https://github.com/tim-smart/effect-atom) ships +> Effect v4 support, the recommended foundation will switch to it. The hook +> return shape and `RuntimeProvider` API in this guide are deliberately close +> to atom-react's `Result.Result` and runtime layer model, so the +> migration is mostly mechanical. Until then, the patterns below are +> ~150 lines of code you own. + +## Contents + +1. [Runtime setup](#1-runtime-setup) +2. [Reading data](#2-reading-data) +3. [Mutations](#3-mutations) +4. [Forms with validation](#4-forms-with-validation) +5. [Testing with a mock layer](#5-testing-with-a-mock-layer) +6. [Caveats](#6-caveats) +7. [What's next](#7-whats-next) + +--- + +## 1. Runtime setup + +Build a `ManagedRuntime` from a Firestore `Layer` at app root and expose it via +React Context. Components read the runtime via `useRuntime()` (rarely needed +directly — the hooks below use it for you). + +```tsx +// example/app/src/app/app.tsx +import { useMemo } from 'react'; +import { Client } from '@effect-firebase/client'; +import { initializeApp } from 'firebase/app'; +import { initializeFirestore, connectFirestoreEmulator } from 'firebase/firestore'; +import { RuntimeProvider } from '../lib/effect-react.js'; + +export function App({ children }: { children: React.ReactNode }) { + const layer = useMemo(() => { + const app = initializeApp({ projectId: 'effect-firebase-example' }); + const firestore = initializeFirestore(app, { ignoreUndefinedProperties: true }); + connectFirestoreEmulator(firestore, 'localhost', 8080); + return Client.layer({ firestore }); + }, []); + + return {children}; +} +``` + +Two things matter here: + +- **Wrap the layer in `useMemo`.** `RuntimeProvider` rebuilds (and disposes) the + runtime whenever `layer` identity changes. Without `useMemo`, every render + produces a new layer, and the runtime is torn down on every render — your + streams will never stabilize. +- **The runtime is disposed automatically** when `` unmounts. + +The full hook module is at +[`example/app/src/lib/effect-react.tsx`](./example/app/src/lib/effect-react.tsx). + +## 2. Reading data + +### One-shot reads + +```tsx +import { Effect } from 'effect'; +import { PostRepository } from '@example/shared'; +import { useEffectQuery } from '../lib/effect-react.js'; + +const getPost = (id: PostId) => + PostRepository.pipe(Effect.flatMap((r) => r.getById(id))); + +function PostView({ id }: { id: PostId }) { + const result = useEffectQuery(() => getPost(id), [id]); + + if (result._tag === 'Initial') return ; + if (result._tag === 'Failure') return ; + return ; +} +``` + +`useEffectQuery(make, deps)`: + +- Re-runs the Effect when any `deps` value changes. +- Returns `Result` — discriminate on `_tag`: `'Initial' | 'Success' | 'Failure'`. +- Provides a `refetch()` you can call to re-execute on demand. +- Interrupts the in-flight fiber on unmount or deps change (no orphaned work). + +### Live subscriptions + +```tsx +import { Effect, Stream } from 'effect'; +import { useEffectStream } from '../lib/effect-react.js'; + +const latestPostsStream = () => + Stream.unwrap(PostRepository.pipe(Effect.map((r) => r.latestPosts()))); + +function PostList() { + const result = useEffectStream(latestPostsStream, []); + + if (result._tag === 'Initial') return ; + if (result._tag === 'Failure') return ; + return <>{result.value.map((p) => )}; +} +``` + +`useEffectStream(make, deps)` runs a `Stream`; the result re-renders with each +emission. Cleanup interrupts the fiber on unmount or deps change. + +### Why thunk-shaped `make` arguments? + +Both hooks take a `() => Effect.Effect<...>` (or `() => Stream.Stream<...>`) +rather than an Effect/Stream value directly. This lets you reference local +variables inside without re-memoizing the Effect — only the `deps` array +controls re-execution. It also matches how +[`@effect-atom/atom-react`](https://github.com/tim-smart/effect-atom) shapes +its atoms, so the migration story is straightforward. + +## 3. Mutations + +```tsx +import { Effect } from 'effect'; +import { useEffectMutation } from '../lib/effect-react.js'; + +const addPost = (data: typeof PostModel.insert.Type) => + PostRepository.pipe(Effect.flatMap((r) => r.add(data))); + +function CreatePost() { + const create = useEffectMutation(addPost); + + return ( + + ); +} +``` + +`useEffectMutation(make)`: + +- Returns `{ mutate, state, reset }`. +- `mutate(...args)` returns a `Promise` — `await` it from a form submit, + branch on the rejection if you need to handle errors at the call site. +- `state` is a `Result` reflecting the last attempt; use it to render + success or error UI. +- In-flight writes are **not interrupted on unmount** — a half-completed + Firestore write would be worse than letting it finish. State updates after + unmount are skipped via a mounted ref. + +## 4. Forms with validation + +`effect/Schema` implements +[Standard Schema v1](https://github.com/standard-schema/standard-schema), so +[`@tanstack/react-form`](https://tanstack.com/form/latest) accepts an +effect/Schema validator directly. + +```tsx +import { Schema } from 'effect'; +import { useForm } from '@tanstack/react-form'; + +const PostFormSchema = Schema.Struct({ + title: Schema.NonEmptyString, + content: Schema.NonEmptyString, +}); + +function PostForm() { + const create = useEffectMutation(addPost); + + const form = useForm({ + defaultValues: { title: '', content: '' }, + validators: { onChange: Schema.toStandardSchemaV1(PostFormSchema) }, + onSubmit: async ({ value }) => { + await create.mutate({ ...value, /* fill required fields */ }); + form.reset(); + }, + }); + + return ( +
{ e.preventDefault(); form.handleSubmit(); }}> + + {(field) => ( + field.handleChange(e.target.value)} + onBlur={field.handleBlur} + error={field.state.meta.isTouched ? field.state.meta.errors[0]?.message : undefined} + /> + )} + + {/* ... */} + [s.canSubmit, s.isSubmitting] as const}> + {([canSubmit, isSubmitting]) => ( + + )} + +
+ ); +} +``` + +Field-level validators take the same form: a single-field +`Schema.toStandardSchemaV1(...)` passed to `validators.onChange` on +``. No glue helper required. + +See [`example/app/src/routes/firestore.tsx`](./example/app/src/routes/firestore.tsx) +for the full pattern, including edit-mode re-keying (``). + +## 5. 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 to `` in tests; the +components under test never change. + +```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 { RuntimeProvider } from '../lib/effect-react.js'; +import { PostList } from '../routes/firestore.js'; + +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(); +}); +``` + +Vitest needs `environment: 'jsdom'` for React testing — see the +`test` block in [`example/app/vite.config.ts`](./example/app/vite.config.ts). + +## 6. Caveats + +- **Defects vs typed errors.** `useEffectQuery` / `useEffectStream` surface the + typed `E` channel; defects (`Effect.die`, thrown JS errors) are logged via + `console.error` and don't update state. Audit your repository functions to + ensure failures you want to render are typed, not defects. +- **Stable layer identity.** The runtime is rebuilt whenever the `layer` prop + identity changes. Always memoize the layer at the boundary. +- **No mutation interruption.** `useEffectMutation` does not interrupt + in-flight writes when the component unmounts; the post-completion `setState` + is just skipped. If you need cancellation, expose an `AbortSignal` and + thread it via `runtime.runPromise(effect, { signal })` in a custom hook. +- **Stream completion vs Initial state.** `useEffectStream` stays in `Initial` + until the first emission. A stream that emits nothing (e.g. empty Firestore + query never yields) stays in `Initial` forever. If you need an explicit + "loaded but empty" state, wrap the stream so it always emits at least once. + +## 7. What's next + +When [`@effect-atom/atom-react`](https://github.com/tim-smart/effect-atom) +ships Effect v4 support, the recommended foundation will be: + +- `Atom.runtime((get) => get(firestoreLayerAtom))` as the runtime binding — + the layer is itself an atom, so tests can swap it via + `RegistryProvider.initialValues` instead of a `layer` prop. +- `Atom.family((id) => runtime.atom(repositoryEffect.pipe(Effect.flatMap((r) => r.getByIdStream(id)))))` + for keyed live atoms — multiple components reading the same id share one + subscription automatically. +- `Atom.fn(make)` for mutations — no need for a per-component `useEffectMutation`. +- Suspense and error boundaries via `useAtomSuspense`. + +The hooks in this guide intentionally avoid features that atom-react covers +better (refcounted subscriptions, suspense, registry-level overrides) so the +migration is bounded. Watch +[tim-smart/effect-atom](https://github.com/tim-smart/effect-atom) for the +v4-compatible release. + +**TanStack Query** is not integrated here. For real-time Firestore data, +streams are the source of truth and Query's `queryFn` (which resolves once) +fights the abstraction. If you have a strong reason to bring it in for +non-Firestore data, layer it on top — it doesn't conflict with anything above. diff --git a/example/app/package.json b/example/app/package.json index b51585b..f17fbf9 100644 --- a/example/app/package.json +++ b/example/app/package.json @@ -18,6 +18,7 @@ "react": "19.2.4", "@nx/react": "22.4.5", "@tanstack/react-router": "^1.139.3", + "@tanstack/react-form": "^1.32.0", "react-dom": "19.2.4", "@tanstack/react-router-devtools": "^1.139.3", "vite": "7.1.8", @@ -27,6 +28,7 @@ }, "devDependencies": { "@effect-firebase/client": "workspace:*", + "@effect-firebase/mock": "workspace:*", "@example/shared": "workspace:*", "effect-firebase": "workspace:*", "vite": "7.1.8" diff --git a/example/app/src/__tests__/firestore.test.tsx b/example/app/src/__tests__/firestore.test.tsx new file mode 100644 index 0000000..5b4212d --- /dev/null +++ b/example/app/src/__tests__/firestore.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react'; +import { Stream } from 'effect'; +import { MockFirestoreService } from '@effect-firebase/mock'; +import { describe, it, expect } from 'vitest'; +import { RuntimeProvider } from '../lib/effect-react.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..eda57ca 100644 --- a/example/app/src/app/app.tsx +++ b/example/app/src/app/app.tsx @@ -1,39 +1,48 @@ +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 SideMenu from '../components/menu/side-menu.js'; import MenuItem from '../components/menu/menu-item.js'; +import { RuntimeProvider } from '../lib/effect-react.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 }); + }, []); return ( -
- - - - - + +
+ + + + + - {/* Main content area */} -
-
{children}
-
-
+ {/* Main content area */} +
+
{children}
+
+
+
); } diff --git a/example/app/src/lib/effect-react.tsx b/example/app/src/lib/effect-react.tsx new file mode 100644 index 0000000..3c192e9 --- /dev/null +++ b/example/app/src/lib/effect-react.tsx @@ -0,0 +1,152 @@ +import { Cause, Effect, Fiber, Layer, ManagedRuntime, Stream } from 'effect'; +import * as React from 'react'; + +export type Result = + | { readonly _tag: 'Initial' } + | { readonly _tag: 'Success'; readonly value: A } + | { readonly _tag: 'Failure'; readonly error: E }; + +const RuntimeContext = React.createContext | null>(null); + +export interface RuntimeProviderProps { + readonly layer: Layer.Layer; + readonly children: React.ReactNode; +} + +export function RuntimeProvider({ + layer, + children, +}: RuntimeProviderProps) { + const runtime = React.useMemo(() => ManagedRuntime.make(layer), [layer]); + React.useEffect(() => () => void runtime.dispose(), [runtime]); + return ( + } + > + {children} + + ); +} + +export function useRuntime(): ManagedRuntime.ManagedRuntime< + R, + never +> { + const r = React.useContext(RuntimeContext); + if (!r) throw new Error('useRuntime: must be used inside '); + return r as ManagedRuntime.ManagedRuntime; +} + +export function useEffectQuery( + make: () => Effect.Effect, + deps: React.DependencyList, +): Result & { readonly refetch: () => void } { + const runtime = useRuntime(); + const [tick, setTick] = React.useState(0); + const [state, setState] = React.useState>({ _tag: 'Initial' }); + + React.useEffect(() => { + setState({ _tag: 'Initial' }); + const fiber = runtime.runFork(make()); + fiber.addObserver((exit) => { + if (exit._tag === 'Success') { + setState({ _tag: 'Success', value: exit.value }); + return; + } + if (Cause.hasInterruptsOnly(exit.cause)) return; + const failure = Cause.findErrorOption(exit.cause); + if (failure._tag === 'Some') { + setState({ _tag: 'Failure', error: failure.value }); + } else { + // Defect — log; typed E is not available for a defect-only cause + console.error('useEffectQuery defect:', Cause.squash(exit.cause)); + } + }); + return () => { + runtime.runFork(Fiber.interrupt(fiber)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runtime, tick, ...deps]); + + const refetch = React.useCallback(() => setTick((t) => t + 1), []); + return { ...state, refetch }; +} + +export function useEffectStream( + make: () => Stream.Stream, + deps: React.DependencyList, +): Result { + const runtime = useRuntime(); + const [state, setState] = React.useState>({ _tag: 'Initial' }); + + React.useEffect(() => { + setState({ _tag: 'Initial' }); + const program = Stream.runForEach(make(), (a) => + Effect.sync(() => setState({ _tag: 'Success', value: a })), + ); + const fiber = runtime.runFork(program); + fiber.addObserver((exit) => { + if (exit._tag === 'Success') return; + if (Cause.hasInterruptsOnly(exit.cause)) return; + const failure = Cause.findErrorOption(exit.cause); + if (failure._tag === 'Some') { + setState({ _tag: 'Failure', error: failure.value }); + } else { + console.error('useEffectStream defect:', Cause.squash(exit.cause)); + } + }); + return () => { + runtime.runFork(Fiber.interrupt(fiber)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [runtime, ...deps]); + + return state; +} + +export interface UseEffectMutation { + readonly mutate: (...args: Args) => Promise
; + readonly state: Result; + readonly reset: () => void; +} + +export function useEffectMutation( + make: (...args: Args) => Effect.Effect, +): UseEffectMutation { + const runtime = useRuntime(); + const makeRef = React.useRef(make); + React.useEffect(() => { + makeRef.current = make; + }); + const mountedRef = React.useRef(true); + React.useEffect( + () => () => { + mountedRef.current = false; + }, + [], + ); + + const [state, setState] = React.useState>({ _tag: 'Initial' }); + + const mutate = React.useCallback( + (...args: Args) => + runtime + .runPromise(makeRef.current(...args)) + .then((value) => { + if (mountedRef.current) setState({ _tag: 'Success', value }); + return value; + }) + .catch((error: E) => { + if (mountedRef.current) setState({ _tag: 'Failure', error }); + throw error; + }), + [runtime], + ); + + const reset = React.useCallback(() => setState({ _tag: 'Initial' }), []); + + return { mutate, state, reset }; +} diff --git a/example/app/src/routes/firestore.tsx b/example/app/src/routes/firestore.tsx index f683f79..19ad1b9 100644 --- a/example/app/src/routes/firestore.tsx +++ b/example/app/src/routes/firestore.tsx @@ -1,9 +1,13 @@ -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 { Effect, Option, Schema, Stream, DateTime } from 'effect'; +import { useForm } from '@tanstack/react-form'; +import { + PostRepository, + PostModel, + PostId, + AuthorId, +} from '@example/shared'; import { Button, Card, @@ -14,97 +18,75 @@ import { Spinner, TextArea, } from '../components/core'; +import { useEffectMutation, useEffectStream } from '../lib/effect-react.js'; export const Route = createFileRoute('/firestore')({ component: RouteComponent, }); type Post = typeof PostModel.Type; +type PostInsert = typeof PostModel.insert.Type; +type PostUpdate = typeof PostModel.update.Type; +type EditingPost = { readonly id: typeof PostId.Type; readonly title: string; readonly content: string }; + +// Repository operations — the repository is itself an Effect, so we +// flatMap through it. The FirestoreService is supplied by the runtime layer. +const latestPostsStream = () => + Stream.unwrap(PostRepository.pipe(Effect.map((r) => r.latestPosts()))); + +const addPost = (data: PostInsert) => + PostRepository.pipe(Effect.flatMap((r) => r.add(data))); + +const updatePost = (input: { + readonly id: typeof PostId.Type; + readonly data: Partial>; +}) => + PostRepository.pipe( + Effect.flatMap((r) => r.update(input.id, input.data)), + ); + +const deletePost = (id: typeof PostId.Type) => + PostRepository.pipe(Effect.flatMap((r) => r.delete(id))); + +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'); - }); - }, []); +function PostForm({ + editing, + onDone, +}: { + editing: EditingPost | null; + onDone: () => void; +}) { + const create = useEffectMutation(addPost); + const update = useEffectMutation(updatePost); - 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; - - 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.mutate({ + 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.mutate({ + title: value.title, + content: value.content, author: AuthorId.make('1'), createdAt: undefined, updatedAt: undefined, @@ -112,46 +94,150 @@ 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); - } - }; + form.reset(); + onDone(); + }, + }); + + return ( + + {editing ? 'Edit Post' : 'Create New Post'} + +
{ + e.preventDefault(); + form.handleSubmit(); + }} + > + + {(field) => ( + field.handleChange(e.target.value)} + error={ + field.state.meta.isTouched + ? field.state.meta.errors[0]?.message + : undefined + } + onBlur={field.handleBlur} + /> + )} + + + {(field) => ( +