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 — copy what fits,
adapt the rest.
The patterns are built on
@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.
- Runtime setup
- Repository atoms
- Reading data
- Mutations
- Pagination
- Forms with validation
- Testing with a mock layer
- Developing against the mock backend
- Caveats
The runtime is composed from two atoms:
- A layer atom that holds the
Layer<FirestoreService>. This is the test seam — production code seeds it viaRegistryProvider.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 viaruntime.atom(...)/runtime.fn(...)so they receiveFirestoreServicefrom the configured layer.
// 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:
// 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 }) {
// useState initializer: Firebase setup runs once per mount and the layer
// keeps a stable identity.
const [layer] = useState(() => {
const firestore = initializeFirestore(initializeApp({...}), {...});
connectFirestoreEmulator(firestore, 'localhost', 8080);
return Client.layer({ firestore });
});
return (
<RegistryProvider initialValues={[[firestoreLayerAtom, layer] as const]}>
{children}
</RegistryProvider>
);
}Create the layer in a useState initializer so Firebase initialization runs
once per mount with a stable identity (a side-effecting useMemo is rejected
by the React Compiler lint). 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.
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.
// 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 byarg(usingEqual-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)andclientRuntime.atom(stream)are overloaded; both produce anAtom<AsyncResult<A, E>>.clientRuntime.fn(effectFn)produces a writable atom whose value is theAsyncResultof 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 withreactivityKeyson 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 passconcurrent: true.
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:
function PostView({ id }: { id: typeof PostId.Type }) {
const result = useAtomValue(postByIdLiveAtom(id));
// ...
}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; returnsvoid.'promise'— await the result; rejects on failure.'promiseExit'— await anExit<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].
Firestore paginates by cursor, not by offset — there is no "jump to page 7". Two patterns cover the real use cases, and they mirror what the official FirebaseUI libraries do (FlutterFire UI uses the first, FirebaseUI-Android's Paging 3 adapter the second):
For a live feed with "load more" / infinite scroll, don't stitch cursor pages
together — a single snapshot listener whose limit grows by one page at a
time is simpler and immune to documents shifting between page boundaries.
makePaginatedQueryAtom
(example/app/src/lib/pagination.ts)
packages the state machine:
// example/app/src/lib/atoms.ts
export const paginatedPostsAtom = makePaginatedQueryAtom(clientRuntime, {
pageSize: 5,
stream: (limit) =>
Stream.unwrap(
Effect.map(PostRepository, (r) =>
r.queryStream(
pipe(
Query.orderBy<typeof PostModel, 'createdAt'>('createdAt', 'desc'),
Query.addLimit(limit),
),
),
),
),
});function PostList() {
const result = useAtomValue(paginatedPostsAtom);
const fetchMore = useAtomSet(paginatedPostsAtom);
return AsyncResult.builder(result)
.onInitial(() => <Spinner />)
.onFailure((cause) => <ErrorState message={Cause.pretty(cause)} />)
.onSuccess(({ items, hasMore, isFetchingMore }) => (
<>
{items.map((p) => (
<PostCard key={p.id} post={p} />
))}
{hasMore && (
<Button isLoading={isFetchingMore} onClick={() => fetchMore()}>
Load more
</Button>
)}
</>
))
.exhaustive();
}For infinite scroll, swap the button for an IntersectionObserver sentinel
that calls fetchMore() when it becomes visible; repeated calls while a
fetch is in flight (or when hasMore is false) are no-ops.
(The example app additionally wraps the atom in an Atom.family keyed by the
mock epoch — see §8 — so devtools
state toggles mint a fresh atom with the window reset to one page.)
How it works: the atom subscribes with limit(pages * pageSize + 1) — one
probe row beyond the visible window, never surfaced in items, so
hasMore = rows.length > visible is exact. Each fetchMore re-subscribes
with a larger limit; already-synced documents are served from Firestore's
local cache, and the whole window stays live through a single listener.
For one-shot page-at-a-time UIs, use Query.startAfter with the last row's
order-field value plus its document ID as the cursor (the ID tiebreaker
keeps pages exact when several rows share the same createdAt), and keep a
stack of cursors: push to go forward, pop to go back. Define an
Atom.family keyed by the cursor, serialized to a string — families key by
Equal equality, so prefer a primitive:
/** "<epochMillis>:<docId>" of the previous page's last row; null = first page. */
type PageCursor = string | null;
const cursorFor = (post: typeof PostModel.Type): PageCursor =>
`${DateTime.toEpochMillis(post.createdAt)}:${post.id}`;
export const postsPageAtom = Atom.family((cursor: PageCursor) =>
clientRuntime
.atom(
Effect.gen(function* () {
const repo = yield* PostRepository;
const [millis, id] = cursor === null ? [] : cursor.split(/:(.*)/s);
return yield* repo.query(
pipe(
Query.orderBy<typeof PostModel, 'createdAt'>('createdAt', 'desc'),
Query.addOrderByDocumentId('desc'),
cursor === null
? (q: Query.Query<typeof PostModel>) => q
: Query.addStartAfter(
FirestoreSchema.Timestamp.fromMillis(Number(millis)),
id,
),
Query.addLimit(PAGE_SIZE),
),
);
}),
)
.pipe(Atom.withReactivity(['posts'])),
);function PaginatedPosts() {
// cursors[i] opens page i; null opens the first page
const [cursors, setCursors] = useState<Array<PageCursor>>([null]);
const result = useAtomValue(postsPageAtom(cursors[cursors.length - 1]));
// render items, then:
// Previous: disabled={cursors.length === 1}
// onClick={() => setCursors((c) => c.slice(0, -1))}
// Next: disabled={posts.length < PAGE_SIZE}
// onClick={() => setCursors((c) => [
// ...c, cursorFor(posts[posts.length - 1]),
// ])}
}These are one-shot reads, so pair Atom.withReactivity(['posts']) with
reactivityKeys: ['posts'] on mutations to refresh mounted pages. Add
Atom.setIdleTTL to the family if back-navigation should render instantly
from the still-warm previous page.
- Cursor values are encoded like document data. Decoded models expose
timestamps as Effect
DateTimevalues and the converters encode them to nativeTimestamps, soQuery.addStartAfter(post.createdAt)works — as doFirestoreSchema.Timestampvalues and plain strings/numbers. - Break ties on the order field. If the field can hold duplicate values,
a single-value cursor can skip or repeat rows across a page boundary — a
value cursor excludes every row matching the cursor values, not just the
one you paged past. The recipe above guards against this by adding
Query.addOrderByDocumentId()after the primaryorderByand passing the last document's ID as a second cursor value:Query.addStartAfter(lastCreatedAt, lastDocId). Server-generated timestamps rarely collide; ratings, counts, and user-entered dates do. hasMore: fetch one row beyond the page (limit(pageSize + 1)) and slice it off for an exact answer — the growing-limit helper does this internally.posts.length < PAGE_SIZEis a cheaper heuristic that shows one dead "Next" click when the collection size is an exact multiple of the page size.
effect/Schema implements
Standard Schema v1, and
@tanstack/react-form accepts a Standard
Schema validator directly. Wrap your schema with Schema.toStandardSchemaV1
and pass it to validators.onChange:
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
for the full form including edit mode (re-key the form on the editing id to
load fresh defaults).
@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:
// 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.
For building pages, @effect-firebase/mock goes further than per-method
overrides: make() returns a full in-memory backend seeded from
schema-encoded fixtures, with a controller for toggling every collection
between data / empty / loading / error at runtime. Because the layer atom
is the only seam, the swap is one initialValues entry:
// lib/mock.ts — shared by the app runtime and the devtools panel
export const mockBackend = make({
fixtures: [
fixture(PostModel, { collectionPath: 'posts', idField: 'id', docs: [...] }),
],
});
// app.tsx — seed the registry with the mock instead of Client.layer
<RegistryProvider
initialValues={[[firestoreLayerAtom, Layer.orDie(mockBackend.layer)] as const]}
>@effect-firebase/devtools ships the controller as a TanStack Devtools
plugin, so the states can be flipped from a panel while the page is running:
import { TanStackDevtools } from '@tanstack/react-devtools';
import { firestoreMockPlugin } from '@effect-firebase/devtools';
<TanStackDevtools
plugins={[
firestoreMockPlugin(mockBackend.controller, {
// `loading` streams never emit and `error` streams fail terminally
// (onSnapshot semantics), while atom results retain their previous
// value across refreshes and remounts. Bumping an epoch that keys the
// read atoms (Atom.family) gives them a fresh identity, so they
// re-subscribe from Initial against the toggled state.
onStateChange: () => bumpEpoch((epoch) => epoch + 1),
}),
]}
/>;The example app wires this up behind an env flag — run pnpm example:mock
and open the devtools panel on the Firestore page. See
example/app/src/lib/atoms.ts (the
mockEpochAtom / Atom.family pattern),
example/app/src/lib/mock.ts and
example/app/src/app/app.tsx.
@effect/atom-reactis lockstep witheffectbetas. Each release of@effect/atom-react@4.0.0-beta.Npeer-depends oneffect@^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).initialValuesis read only at registry creation, so it can't be used to swap the layer later. - Atom families key by
Equalequality. Branded ids work out of the box; object keys need to be eitherEqual-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')orAtom.keepAlive, or set a registry-widedefaultIdleTTLonRegistryProvider. During a TTL window the subscription stays live (not paused), and a remount reattaches to it; after the TTL nothing is cached. unstable/reactivityis unstable. The Atom module lives in Effect'sunstable/namespace until v4 stable. Treat API churn between betas as possible — pin tightly and update intentionally.