Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 162 additions & 8 deletions REACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@ released against.
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. [Developing against the mock backend](#7-developing-against-the-mock-backend)
8. [Caveats](#8-caveats)
5. [Pagination](#5-pagination)
6. [Forms with validation](#6-forms-with-validation)
7. [Testing with a mock layer](#7-testing-with-a-mock-layer)
8. [Developing against the mock backend](#8-developing-against-the-mock-backend)
9. [Caveats](#9-caveats)

---

Expand Down Expand Up @@ -241,7 +242,160 @@ function CreatePost() {
If you also need the `AsyncResult` state (loading / success / error) for UI,
use `useAtom(atom)` to get both `[result, set]`.

## 5. Forms with validation
## 5. Pagination

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):

### 5a. Realtime infinite loading — growing limit

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`](./example/app/src/lib/pagination.ts))
packages the state machine:

```ts
// 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),
),
),
),
),
});
```

```tsx
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](#8-developing-against-the-mock-backend) — 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.

### 5b. Prev/next page buttons — cursor stack

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:

```ts
/** "<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),
),
Comment thread
fwal marked this conversation as resolved.
);
}),
)
.pipe(Atom.withReactivity(['posts'])),
);
```

```tsx
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.

### Pagination notes

- **Cursor values are encoded like document data.** Decoded models expose
timestamps as Effect `DateTime` values and the converters encode them to
native `Timestamp`s, so `Query.addStartAfter(post.createdAt)` works — as
do `FirestoreSchema.Timestamp` values 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 primary `orderBy` and 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_SIZE` is a cheaper heuristic that shows
one dead "Next" click when the collection size is an exact multiple of the
page size.

## 6. Forms with validation

`effect/Schema` implements
[Standard Schema v1](https://github.com/standard-schema/standard-schema), and
Expand Down Expand Up @@ -290,7 +444,7 @@ See [`example/app/src/routes/firestore.tsx`](./example/app/src/routes/firestore.
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
## 7. Testing with a mock layer

`@effect-firebase/mock` exports `MockFirestoreService(overrides)`, which
returns a `Layer<FirestoreService>` whose methods throw by default but accept
Expand Down Expand Up @@ -323,7 +477,7 @@ 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. Developing against the mock backend
## 8. Developing against the mock backend

For building pages, `@effect-firebase/mock` goes further than per-method
overrides: `make()` returns a full in-memory backend seeded from
Expand Down Expand Up @@ -373,7 +527,7 @@ and open the devtools panel on the Firestore page. See
[`example/app/src/lib/mock.ts`](./example/app/src/lib/mock.ts) and
[`example/app/src/app/app.tsx`](./example/app/src/app/app.tsx).

## 8. Caveats
## 9. 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
Expand Down
67 changes: 66 additions & 1 deletion example/app/src/__tests__/firestore.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,41 @@
import { render, screen } from '@testing-library/react';
import { fireEvent, render, screen } from '@testing-library/react';
import { Stream } from 'effect';
import { MockFirestoreService } from '@effect-firebase/mock';
import { FirestoreSchema } from 'effect-firebase';
import type { Snapshot } from 'effect-firebase';
import { RegistryProvider } from '@effect/atom-react';
import { describe, it, expect } from 'vitest';
import { firestoreLayerAtom } from '../lib/atoms.js';
import { PostList } from '../routes/firestore.js';

const makeSnapshot = (i: number): Snapshot => [
{ id: `post-${i}`, path: `posts/post-${i}` },
{
title: `Post ${i}`,
content: `Content ${i}`,
author: FirestoreSchema.Reference.makeFromPath('authors/1'),
createdAt: FirestoreSchema.Timestamp.fromMillis(1700000000000 - i * 1000),
updatedAt: FirestoreSchema.Timestamp.fromMillis(1700000000000 - i * 1000),
checked: false,
list: [],
},
];

/** Mock layer whose streamQuery honors the query's Limit constraint. */
const layerWithPosts = (count: number) => {
const snapshots = Array.from({ length: count }, (_, i) => makeSnapshot(i));
return MockFirestoreService({
streamQuery: (_path, constraints) => {
const limit = constraints.find(
(c): c is Extract<typeof c, { count: number }> => c._tag === 'Limit',
)?.count;
return Stream.make(
limit === undefined ? snapshots : snapshots.slice(0, limit),
);
},
});
};

describe('PostList', () => {
it('renders the empty state when the mock layer yields no posts', async () => {
const layer = MockFirestoreService({
Expand All @@ -20,4 +50,39 @@ describe('PostList', () => {

expect(await screen.findByText(/No posts found/i)).toBeTruthy();
});

it('paginates: shows one page plus a Load more button that grows the window', async () => {
render(
<RegistryProvider
initialValues={[[firestoreLayerAtom, layerWithPosts(7)] as const]}
>
<PostList onEdit={() => undefined} />
</RegistryProvider>,
);

// First window: pageSize (5) items, the extra probe row is not rendered.
expect(await screen.findByText('Post 0')).toBeTruthy();
expect(screen.getAllByText(/^Post \d+$/)).toHaveLength(5);

fireEvent.click(screen.getByRole('button', { name: /Load more/i }));

// Second window: all 7 items, and no further page exists.
expect(await screen.findByText('Post 6')).toBeTruthy();
expect(screen.getAllByText(/^Post \d+$/)).toHaveLength(7);
expect(screen.queryByRole('button', { name: /Load more/i })).toBeNull();
});

it('hides Load more when the collection fits within one page', async () => {
render(
<RegistryProvider
initialValues={[[firestoreLayerAtom, layerWithPosts(5)] as const]}
>
<PostList onEdit={() => undefined} />
</RegistryProvider>,
);

expect(await screen.findByText('Post 0')).toBeTruthy();
expect(screen.getAllByText(/^Post \d+$/)).toHaveLength(5);
expect(screen.queryByRole('button', { name: /Load more/i })).toBeNull();
});
});
28 changes: 26 additions & 2 deletions example/app/src/lib/atoms.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { Effect, Layer, Stream } from 'effect';
import { Effect, Layer, Stream, pipe } from 'effect';
import { Atom } from 'effect/unstable/reactivity';
import { FirestoreService } from 'effect-firebase';
import { FirestoreService, Query } from 'effect-firebase';
import { PostId, PostModel, PostRepository } from '@example/shared';
import { makePaginatedQueryAtom } from './pagination.js';

/**
* Indirection that makes the Firestore layer swappable at the registry level.
Expand Down Expand Up @@ -81,6 +82,29 @@ export const latestPostsAtom = Atom.family((_epoch: number) =>
),
);

// Realtime paginated feed (growing-limit pattern, REACT.md §Pagination).
// Reading yields { items, hasMore, isFetchingMore }; writing (any value)
// grows the window by one page. The whole window is one live listener.
// Keyed by the mock epoch like latestPostsAtom above: a bumped epoch mints a
// fresh paginated atom (window reset to one page) that re-subscribes from
// `Initial`; outside mock mode the epoch is always `0`.
export const paginatedPostsAtom = Atom.family((_epoch: number) =>
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),
),
),
),
),
}),
);

// 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
Expand Down
Loading
Loading