Skip to content

docs(scoped-atoms): provide a family's atom for values from the server - #45

Merged
jarrednorrisdev merged 5 commits into
mainfrom
docs/scoped-family
Oct 10, 2026
Merged

jarrednorrisdev merged 5 commits into
mainfrom
docs/scoped-family

Conversation

@jarrednorrisdev

@jarrednorrisdev jarrednorrisdev commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Recommends a pattern for scoped atoms whose value comes from the server, and pins it with tests.

Docs note: #46, stacked on this PR, rewrites the scoped-atoms page and moves this PR's "Values from the server" section to the Hydration page in clearer form. Merge the two back to back; this PR's lasting changes are the tests and the ScopedAtom.make doc comment. Squash-merge it: its history includes a docs rewrite that was later reverted. Follow-up to #38.

Background: a scoped atom makes one atom per provider component: User.provide(id) creates it for that component's subtree, and User.use() below reads it, like Svelte context. A family makes one atom per input: userAtom(1) returns the same atom wherever it's called. A serializable atom's value is sent from the server to the browser under its serialization key, and the registry stores it under that key, so atoms with one key share one value.

The problem with the old advice

The docs said to put the input in the key:

export const User = ScopedAtom.make((id: string) =>
  Atom.make(fetchUser(id)).pipe(
    Atom.serializable({ key: `user-${id}`, schema: UserResult })
  )
);
<!-- a server-rendered comment thread: each comment provides its author -->
{#each comments as comment}
  <UserProvider id={comment.authorId}>
    <CommentCard {comment} />  <!-- its <Avatar /> and <AuthorName /> call User.use() -->
  </UserProvider>
{/each}

The provider per comment is the point of the scoped atom: the card's parts get the author with User.use(), without authorId being passed down to each of them.

Bug: when one author comments twice, the server render fails with Two different atoms share the serialization key "user-a". Each of their two providers made its own atom, and both have the key user-a. The pattern is also only scoped in name: because the value lives under its key, every copy shares it, and remounting a provider with {#key} doesn't reset it.

The new advice

Make the atom a family, and if components below should still reach it without being passed the id, provide the family's atom with a scoped atom:

export const userAtom = Atom.family((id: string) =>
  Atom.make(fetchUser(id)).pipe(
    Atom.serializable({ key: `user-${id}`, schema: UserResult })
  )
);

export const User = ScopedAtom.make((id: string) => userAtom(id), { name: "User" });

Every provider of one id now holds the same atom, so the page above renders on the server, and the browser hydrates both from the one server value. User.use() works as before. No library code changes: this is a documented pattern, checked by tests.

One limit, stated in the docs: a provider reads its input once, so if its id prop can change, read userAtom(id) with a getter instead.

Step through the whole render, from the thread to the browser, with either advice: step 5 is where the old advice fails, at Alice's second comment, and the same step with the new advice.

Tests

In test/scoped.browser.test.ts:

  • The old pattern fails: two plain scoped providers with the same id make the server render throw, which is the claim the docs now make. It sits with the other server tests in "ScopedAtom with server rendering".
  • A plain family works: a thread whose comments read their author straight from the family (Alice, Bob, Alice) renders on the server and hydrates without computing.
  • The new pattern works: two providers with the same id plus a third id render on the server. The browser hydrates all three from the server's values without computing anything, and that still holds after one provider is remounted with {#key}.

Locally, the scoped tests pass in Chromium and WebKit (6 tests each), and format, lint and type checks pass across the repo.

Decisions

  1. No ScopedAtom.family helper. The combination stays a documented two-line pattern; the docs steer people to a plain family first and to this combination only when the parts shouldn't be given the id.
Where to look in the diff
  • Docs: the new "Values from the server" section, with when it happens (a comment thread where an author comments twice), in apps/demo/src/routes/scoped-atoms/+page.md, and a pointer to it from the "Two different atoms share the serialization key" entry in troubleshooting/+page.md.
  • ScopedAtom.make's doc comment in packages/effect-atom-svelte/src/ScopedAtom.ts.
  • Tests: the "ScopedAtom providing a family's atom" block in test/scoped.browser.test.ts. Fixtures: userFamily and FamilyUser in test/fixtures/scoped-seed.ts, ssr-scoped-family.svelte and ssr-scoped-same-input.svelte.
  • An empty changeset: nothing a user installs changes except a doc comment.

🤖 Generated with Claude Code

A scoped atom that makes its own serializable atom gives two providers of one input two different
atoms with one key, which fails the server render. Returning a family's atom from the scoped
atom's factory keeps context access while every provider of one input holds the same atom. Tests
pin both: the family version renders two same-input providers on the server and hydrates without
computing, even across a remount; the plain version fails the render.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev and others added 2 commits October 9, 2026 20:25
Lead with the rule instead of a pattern: a family for data keyed by an id, a scoped atom for state
that belongs to a place on the page. The failure a serializable scoped atom causes is the reason,
and providing a family's atom from a scoped atom is now a one-line aside. A new test renders a
thread that repeats an author through a plain family, on the server and hydrated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The docs again recommend providing a family's atom from a scoped atom for values from the server:
the family gives one atom per id, the scoped atom keeps context access. The plain-family thread
test from the last commit stays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jarrednorrisdev added a commit that referenced this pull request Oct 9, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…er tests

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@jarrednorrisdev
jarrednorrisdev marked this pull request as ready for review October 10, 2026 00:27
@jarrednorrisdev
jarrednorrisdev merged commit 336b8d8 into main Oct 10, 2026
16 checks passed
@jarrednorrisdev
jarrednorrisdev deleted the docs/scoped-family branch October 10, 2026 00:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant