Documentation Index
Fetch the complete documentation index at: https://mintlify.com/tim-smart/effect-atom/llms.txt
Use this file to discover all available pages before exploring further.
Effect Atom provides built-in support for server-side rendering (SSR) through dehydration and hydration APIs that allow you to serialize atom state on the server and restore it on the client.
Overview
The hydration process involves:
- Dehydration: Serialize atom state on the server
- Transfer: Send the serialized state to the client
- Hydration: Restore the state in the client-side registry
This ensures your application can render consistently on both server and client without flickering or layout shifts.
Making atoms serializable
To enable hydration, mark atoms as serializable using Atom.serializable:
import { Atom } from "@effect-atom/atom"
import { Schema } from "effect"
const userSchema = Schema.Struct({
id: Schema.String,
name: Schema.String,
email: Schema.String
})
const userAtom = Atom.make(
Effect.gen(function* () {
return yield* fetchUser()
})
).pipe(
Atom.serializable({
key: "user:123",
schema: userSchema
})
)
The key must be unique across your application and stable across renders.
Dehydrating state on the server
On the server, use Hydration.dehydrate to serialize the registry:
import { Hydration, Registry } from "@effect-atom/atom"
const registry = Registry.make()
// Mount and populate atoms
registry.mount(userAtom)
registry.mount(postsAtom)
// Wait for atoms to load
await new Promise((resolve) => setTimeout(resolve, 100))
// Serialize the state
const dehydratedState = Hydration.dehydrate(registry)
Dehydration options
Control how Initial results are encoded:
const dehydratedState = Hydration.dehydrate(registry, {
encodeInitialAs: "ignore" // default
})
Available options:
"ignore": Skip atoms still in Initial state (default)
"value-only": Include the Initial state value
"promise": Include a promise that resolves when the atom moves out of Initial state
The "promise" mode is useful for streaming SSR where you want to send
initial HTML immediately and stream updates as atoms resolve.
Hydrating state on the client
On the client, use Hydration.hydrate to restore the state:
import { Hydration, Registry } from "@effect-atom/atom"
import { AtomProvider } from "@effect-atom/atom-react"
function App({ dehydratedState }: { dehydratedState: any }) {
const registry = Registry.make()
// Restore the state
Hydration.hydrate(registry, dehydratedState)
return (
<AtomProvider registry={registry}>
<UserProfile />
<PostsList />
</AtomProvider>
)
}
Complete SSR example
Define serializable atoms
Create atoms with unique keys:import { Atom, Registry } from "@effect-atom/atom"
import { Effect, Schema } from "effect"
const User = Schema.Struct({
id: Schema.String,
name: Schema.String
})
const userAtom = Atom.family((userId: string) =>
Atom.make(
Effect.gen(function* () {
// Simulate API call
yield* Effect.sleep(100)
return { id: userId, name: "Alice" }
})
).pipe(
Atom.serializable({
key: `user:${userId}`,
schema: User
})
)
)
Server-side rendering
Populate the registry and dehydrate:// server.ts
import { Hydration, Registry } from "@effect-atom/atom"
import { renderToString } from "react-dom/server"
async function handleRequest(req: Request) {
const registry = Registry.make()
// Pre-load atoms needed for this page
const userId = new URL(req.url).searchParams.get("userId")
if (userId) {
registry.mount(userAtom(userId))
}
// Wait for atoms to resolve
await new Promise((resolve) => setTimeout(resolve, 200))
// Dehydrate the state
const dehydratedState = Hydration.dehydrate(registry)
// Render the app
const html = renderToString(
<App registry={registry} />
)
// Send HTML with embedded state
return new Response(
`<!DOCTYPE html>
<html>
<head><title>App</title></head>
<body>
<div id="root">${html}</div>
<script>
window.__DEHYDRATED_STATE__ = ${JSON.stringify(dehydratedState)};
</script>
<script src="/client.js"></script>
</body>
</html>`,
{ headers: { "Content-Type": "text/html" } }
)
}
Client-side hydration
Hydrate the state before rendering:// client.tsx
import { Hydration, Registry } from "@effect-atom/atom"
import { AtomProvider } from "@effect-atom/atom-react"
import { hydrateRoot } from "react-dom/client"
const registry = Registry.make()
// Hydrate from server state
const dehydratedState = (window as any).__DEHYDRATED_STATE__
if (dehydratedState) {
Hydration.hydrate(registry, dehydratedState)
}
// Hydrate the React app
hydrateRoot(
document.getElementById("root")!,
<AtomProvider registry={registry}>
<App />
</AtomProvider>
)
Dehydrated state structure
From Hydration.ts:13-26, the dehydrated format:
interface DehydratedAtomValue {
readonly "~@effect-atom/atom/DehydratedAtom": true
readonly key: string
readonly value: unknown
readonly dehydratedAt: number
readonly resultPromise?: Promise<unknown> | undefined
}
Each dehydrated atom includes:
key: The unique atom key
value: The encoded value using the atom’s schema
dehydratedAt: Timestamp when dehydrated
resultPromise: Optional promise for streaming updates
Hydration with promises
When using encodeInitialAs: "promise", the client can wait for pending atoms:
const dehydratedState = Hydration.dehydrate(registry, {
encodeInitialAs: "promise"
})
// On the client
Hydration.hydrate(registry, dehydratedState)
// Promises will automatically update the registry when resolved
for (const atom of Hydration.toValues(dehydratedState)) {
if (atom.resultPromise) {
atom.resultPromise.then((value) => {
console.log("Atom resolved:", value)
})
}
}
From Hydration.ts:86-112, the hydration logic handles promises:
export const hydrate = (
registry: Registry.Registry,
dehydratedState: Iterable<DehydratedAtom>
): void => {
for (const datom of (dehydratedState as Iterable<DehydratedAtomValue>)) {
registry.setSerializable(datom.key, datom.value)
// If there's a resultPromise, it means this was in Initial state
// when dehydrated. Wait for it to resolve, then update the registry
if (!datom.resultPromise) continue
datom.resultPromise.then((resolvedValue) => {
// Update the node with the resolved value
const nodes = (registry as any).getNodes()
const node = nodes.get(datom.key)
if (node) {
const atom = node.atom as any
if (atom[Atom.SerializableTypeId]) {
const decoded = atom[Atom.SerializableTypeId].decode(resolvedValue)
node.setValue(decoded)
}
}
})
}
}
Best practices
Use stable keys
Ensure atom keys are deterministic and stable:// Good: Stable key based on ID
Atom.serializable({
key: `user:${userId}`,
schema: userSchema
})
// Bad: Random or timestamp-based keys
Atom.serializable({
key: `user:${Math.random()}`,
schema: userSchema
})
Only serialize necessary atoms
Mark only atoms needed for initial render as serializable:// Serialize: Above-the-fold content
const heroAtom = Atom.make(fetchHero()).pipe(
Atom.serializable({ key: "hero", schema: HeroSchema })
)
// Don't serialize: Below-the-fold, client-only state
const analyticsAtom = Atom.make(trackAnalytics())
Handle missing hydration data
Components should handle cases where hydration data is missing:function UserProfile() {
const result = useAtomValue(userAtom)
// Handle Initial state (not hydrated or loading)
return Result.match(result, {
onInitial: () => <Skeleton />,
onSuccess: (user) => <div>{user.name}</div>,
onFailure: (error) => <Error error={error} />
})
}
Consider payload size
Large dehydrated states increase HTML size:// Measure dehydrated size
const state = Hydration.dehydrate(registry)
const sizeKB = JSON.stringify(state).length / 1024
console.log(`Dehydrated state: ${sizeKB.toFixed(2)}KB`)
If too large, consider:
- Serializing fewer atoms
- Using
encodeInitialAs: "ignore"
- Splitting hydration across routes
Framework integration
Next.js App Router
// app/page.tsx
import { Hydration, Registry } from "@effect-atom/atom"
import { AtomProvider } from "@effect-atom/atom-react"
export default async function Page() {
const registry = Registry.make()
// Pre-load server-side
registry.mount(dataAtom)
await new Promise((resolve) => setTimeout(resolve, 100))
const dehydratedState = Hydration.dehydrate(registry)
return (
<AtomProvider
registry={registry}
dehydratedState={dehydratedState}
>
<Content />
</AtomProvider>
)
}
Remix
// routes/index.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
import { Hydration, Registry } from "@effect-atom/atom"
export async function loader({ request }: LoaderFunctionArgs) {
const registry = Registry.make()
registry.mount(dataAtom)
await new Promise((resolve) => setTimeout(resolve, 100))
return json({
dehydratedState: Hydration.dehydrate(registry)
})
}
export default function Index() {
const { dehydratedState } = useLoaderData<typeof loader>()
return (
<AtomProvider dehydratedState={dehydratedState}>
<Content />
</AtomProvider>
)
}
The hydration APIs work with any SSR framework. The key is to dehydrate on
the server, transfer the state to the client, and hydrate before rendering.
Troubleshooting
Hydration mismatches
If you see hydration errors:
- Ensure keys are stable and deterministic
- Verify schemas match on server and client
- Check that all serializable atoms are mounted on the server
Missing state on client
If atoms show Initial state after hydration:
- Verify the atom is marked with
Atom.serializable
- Check the atom was mounted and loaded on the server
- Ensure
Hydration.hydrate is called before rendering
Large payload sizes
If dehydrated state is too large:
- Use
encodeInitialAs: "ignore" to skip pending atoms
- Only mark critical atoms as serializable
- Consider code-splitting and route-based hydration