jotai-ssr is a utility library for Jotai to facilitate server-side rendering (SSR). It provides helpers for:
- Hydrating atom values from server to client (and optionally re-hydrating).
- Handling SSR scenarios, including React Server Components and soft navigations in frameworks like Next.js, Remix, and Waku.
This library extends or wraps the existing Jotai SSR utilities to provide a more seamless integration with modern SSR setups.
- Installation
- Creating a Safe Store for Each Request
- Hydration
- Streaming Hydration with Async Atoms
- Important Notes on Hydration Logic
- Soft Navigation in SSR Frameworks
- Re-Hydration
npm install jotai-ssr
# or
yarn add jotai-ssr
# or
pnpm add jotai-ssr
When using Jotai in an SSR environment, you must ensure each request has its own store. Relying on a shared, global store (e.g. defaultStore
) across requests can lead to data leakage between different users.
To create an isolated store per request, you should use Provider
for each layout or page that uses Jotai like so:
import { Provider } from 'jotai';
const Page = () => {
return (
<Provider>{/* Your content */}</Provider>
);
};
If you need to pass a custom store to the Provider
, you can do so in one of the following ways:
-
Using useState
'use client'; import { createStore, Provider } from 'jotai'; import { useState } from 'react'; const Page = () => { const [store] = useState(() => createStore()); return <Provider store={store}>{/* your content */}</Provider>; };
-
Using useRef
'use client'; import { createStore, Provider } from 'jotai'; import { useRef } from 'react'; const Page = () => { const storeRef = useRef(undefined); if (!storeRef.current) { storeRef.current = createStore(); } return <Provider store={storeRef.current}>{/* your content */}</Provider>; };
Both approaches ensure a new store instance is created for each request, preventing data from leaking between different users.
When data is fetched on the server and passed to the client, you may want to initialize Jotai atoms with those server-side values. This process is called hydration.
Hydration sets up atoms with initial values so that:
- The server-rendered HTML uses the correct initial state.
- Once the client side finishes React hydration, the atom is already in the correct state without causing extra re-renders.
When dealing with SSR, you often have data fetched on the server that needs to be passed to your components and stored in Jotai atoms. To accomplish this, jotai-ssr provides a useHydrateAtoms
hook similar to the one in Jotai’s jotai/utils
, with a few small differences:
- No
dangerouslyForceHydrate
option - Exported from
jotai-ssr
rather thanjotai/utils
Apart from these differences, the usage is nearly the same as the official Jotai version. This means you can hydrate your atoms with data like so:
'use client'; // If you're using React Server Components (RSC), ensure the file is a Client Component
import { atom, useAtom } from 'jotai';
import { useHydrateAtoms } from 'jotai-ssr';
// Example atom
export const countAtom = atom(0);
interface PageProps {
countFromServer: number;
}
export function Page({ countFromServer }: PageProps) {
// 1. Hydrate the atom with a value fetched on the server
useHydrateAtoms([[countAtom, countFromServer]]);
// 2. Now you can safely use the atom in your component
const [count] = useAtom(countAtom);
return <div>Count: {count}</div>;
}
Here’s what you need to know:
-
Client-Side Usage:
Despite the term “SSR” in its name,useHydrateAtoms
must be called in client code (i.e., a component with'use client'
at the top if you’re using React Server Components). -
Optional
store
Parameter:
Just like the Jotai version, you can target a specific store by providing thestore
option. For example:import { createStore } from 'jotai'; const myStore = createStore(); useHydrateAtoms([[countAtom, 42]], { store: myStore });
-
No
dangerouslyForceHydrate
:
Unlike the original Jotai hook, the jotai-ssr version does not provide adangerouslyForceHydrate
option. If you need more advanced re-hydration behavior, see Re-Hydration.
Tip: Hydrating an atom does not cause additional re-renders if you do it before using the atom in your component. Make sure to call
useHydrateAtoms
at the top level of your component (or inside its parent) so that the initial render already has the right atom values.
For React Server Components (RSC) and for a clearer boundary-based approach, jotai-ssr provides a HydrationBoundary
component:
import { HydrationBoundary } from 'jotai-ssr';
import { countAtom } from './atoms'; // "use client" inside this file
const ServerComponent = async () => {
const countFromServer = await fetchCount();
return (
<HydrationBoundary hydrateAtoms={[[countAtom, countFromServer]]}>
{/* Components that consume countAtom */}
</HydrationBoundary>
);
};
You can pass an optional options
prop, such as { store: myStore }
, if you want to hydrate into a specific store.
Note:
HydrationBoundary
can be used in both Client and Server Components. However, when using it in a Server Component, the atom definitions must be marked with'use client'
. Also, any value you pass for hydration must be serializable. This is becauseHydrationBoundary
itself is a React Client Component.
Some SSR frameworks (like Next.js) support streaming responses so that part of your UI can render before all data is loaded. With Jotai, you can stream data from the server into async atoms. This allows your components to start rendering in a suspended state, then reveal when the data arrives. Here’s how you can do it with jotai-ssr:
First, define an atom that holds a promise:
'use client';
import { atom, useAtomValue } from 'jotai';
// Define an async atom that resolves to a number.
export const countAtom = atom<Promise<number>>();
export const CountComponent = () => {
// Jotai will automatically suspend here until the promise resolves
const count = useAtomValue(countAtom);
return <div>{count}</div>;
};
Note: Because this is an async atom, any component reading it will suspend by default, so you must wrap it with
<Suspense>
.
Next, in your server-side or edge code, you can fetch the data and hydrate the async atom with a promise:
import { HydrationBoundary } from 'jotai-ssr';
import { Suspense } from 'react';
import { countAtom, CountComponent } from './client';
export const StreamingHydration = () => {
// Suppose this fetchCount function returns a Promise<number>
const countPromise = fetchCount();
return (
<HydrationBoundary hydrateAtoms={[[countAtom, countPromise]]}>
<Suspense fallback={<div>loading...</div>}>
<CountComponent />
</Suspense>
</HydrationBoundary>
);
};
- Fetch or stream data (e.g.,
fetchCount()
) on the server. - Hydrate the async atom by passing
[countAtom, countPromise]
toHydrationBoundary
. - Wrap your async-consuming components (
<CountComponent />
) with a<Suspense>
boundary to handle the loading state.
This setup ensures that:
- The async atom suspends until the promise resolves.
- The hydrated value is set immediately in the store, allowing the client to continue with the same promise.
- React’s Suspense boundary displays a fallback (
loading...
) until the atom’s promise settles.
This pattern allows you to combine the power of async atoms with server-side streaming, letting Jotai manage data states in a way that feels natural within React’s Suspense model.
Hydration sets atom initial values. Thus, you should not use the atom in a component before calling useHydrateAtoms
. Instead, do something like this:
Correct usage:
const Component = ({ countFromServer }) => {
useHydrateAtoms([[countAtom, countFromServer]]);
const [count] = useAtom(countAtom);
return <div>{count}</div>;
};
or
const Component = async ({ countFromServer }) => {
return (
<HydrationBoundary hydrateAtoms={[[countAtom, countFromServer]]}>
<CountComponent />
</HydrationBoundary>
);
};
Incorrect usage (atom used before hydration):
// Don't do this
const Component = ({ countFromServer }) => {
const [count] = useAtom(countAtom);
useHydrateAtoms([[countAtom, countFromServer]]);
return <div>{count}</div>;
};
or
// Don't do this
const Component = async ({ countFromServer }) => {
const [count] = useAtom(countAtom);
return (
<HydrationBoundary hydrateAtoms={[[countAtom, countFromServer]]}>
<div>{count}</div>
</HydrationBoundary>
);
};
A single Jotai Provider
shares atom states across its entire tree. Hydrating the same atom in multiple child components can lead to unexpected re-renders. Instead, hydrate each atom once. If you really need separate hydration for the same atom, place them in different providers or use jotai-scope to scope them.
Hydration sets the atom value only on the first render (just like a useState
initial value in React). Subsequent props changes do not cause re-hydration. If a component unmounts and remounts, it will re-hydrate at that time.
In frameworks like Next.js, Remix, and Waku, soft navigation means some part of your layout or component tree does not unmount between page transitions. For instance:
- Next.js App Router:
layout.jsx
might persist across routes. - Remix:
root.jsx
can persist across routes. - Waku:
layout.jsx
can persist across pages.
Note: A similar example is when the path includes a slug, and navigation occurs between pages with different slugs. In such cases, particularly in Remix and Waku, the page component itself tends to persist.
When using soft navigation:
- If your Jotai
Provider
is in a layout that persists, its store does not get recreated on page transitions. Data from previous pages is carried over. - If your Jotai
Provider
is placed in a page component, it will be recreated on each navigation, effectively isolating state per page.
Therefore, be mindful where you place the Provider
or the hydration logic. If a persistent layout hydrates the same atom across different routes, you could trigger unwanted re-renders on route changes. Generally, avoid hydrating atoms in a page if the Provider
is in a layout that persists.
By default, hydration happens only once: on the initial mount. Even if you pass new values to useHydrateAtoms
or HydrationBoundary
after that, the atom values remain as they were set the first time.
However, if you need to re-hydrate (e.g., to sync with the latest server data after a route refresh), you can enable re-hydration:
-
With
useHydrateAtoms
:import { useHydrateAtoms } from 'jotai-ssr'; const Component = ({ countFromServer }) => { useHydrateAtoms([[countAtom, countFromServer]], { enableReHydrate: true }); const [count] = useAtom(countAtom); return <div>{count}</div>; };
-
With
HydrationBoundary
:const ServerComponent = async () => { const countFromServer = await fetchCount(); return ( <HydrationBoundary hydrateAtoms={[[countAtom, countFromServer]]} options={{ enableReHydrate: true }} > <CountComponent /> </HydrationBoundary> ); };
When enableReHydrate
is true
, the component compares the new hydration values (via Object.is
) and re-hydrates if they differ.
In Next.js App Router, calling router.refresh()
or revalidatePath()
triggers server code to re-fetch data, but the same client component instance persists. Normally, useState
or Jotai hydration wouldn’t reset values. By turning on re-hydration, you can ensure your atoms get updated with the newest fetched data.
The same principle applies in Remix or Waku if the layout is partially reused during a slug-based soft navigation.
MIT License. See LICENSE for details.
This is a new package and we would love to hear your feedback. Related discussion: pmndrs/jotai#2692