diff --git a/README.md b/README.md index 89a91e0..60cbb5e 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,33 @@ # jotai-ssr -Jotai utilities for server-side rendering (SSR) -## Motivation -This package provides a new SSR utility designed to replace [`useHydrateAtoms`](https://jotai.org/docs/utilities/ssr), offering a safer and more flexible way to use Jotai in SSR environments. +**jotai-ssr** is a utility library for [Jotai](https://jotai.org/) to facilitate server-side rendering (SSR). It provides helpers for: -This package provides comprehensive support for React Server Components, particularly with the Next.js App Router, addressing several limitations and issues that arise when using Jotai in server-side rendering contexts. +- Creating an **isolated store per request** (to avoid data leak between users). +- **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. -## Feedback -This is a new package and we would love to hear your feedback. -Related discussion: https://github.com/pmndrs/jotai/discussions/2692 +This library extends or wraps the existing Jotai SSR utilities to provide a more seamless integration with modern SSR setups. -## Overview -This package provides 3 boundary components and 1 atom for server-side rendering with Jotai: +--- -- `RenderingBoundary` - A boundary component that should be used at the root of components that will be rendered on the server during navigation, such as Next.js's `page.tsx`, `layout.tsx`, etc. -- `SuspenseBoundary` - A boundary component that should be used at the root of async components wrapped with `React.Suspense`, but only when the component subtree references atoms that have the potential to change while suspended. -- `HydrationBoundary` - A boundary component for hydrating atoms (synchronizing the server-rendered HTML with the client state) on the client side. -- `isHydratingAtom` - An atom that can be used to check if atom hydration is in progress. +## Table of Contents + +1. [Installation](#installation) +2. [Creating a Safe Store for Each Request](#creating-a-safe-store-for-each-request) + 1. [`useCreateStore`](#usecreatestore) + 2. [`SSRProvider`](#ssrprovider) +3. [Hydration](#hydration) + 1. [What is Hydration?](#what-is-hydration) + 2. [How to Hydrate an Atom](#how-to-hydrate-an-atom) + 3. [`HydrationBoundary`](#hydrationboundary) + 4. [Important Notes on Hydration Logic](#important-notes-on-hydration-logic) +4. [Soft Navigation in SSR Frameworks](#soft-navigation-in-ssr-frameworks) +5. [Re-Hydration](#re-hydration) + +--- ## Installation + ```bash npm install jotai-ssr # or @@ -27,189 +36,288 @@ yarn add jotai-ssr pnpm add jotai-ssr ``` -## Usage -This is a Next.js App Router example: +--- -### Define Rendering Boundary -Wrap the components that will be rendered on the server during navigation with `RenderingBoundary`. +## Creating a Safe Store for Each Request -layout.tsx: -```jsx -import { RenderingBoundary } from '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. -export default function Layout({ children }) { - return ( - - - - - {children} - - - - - ) -} -``` +### Using the `useCreateStore` Hook -page.tsx: -```jsx -import { RenderingBoundary } from 'jotai-ssr' +If you create a store manually, you typically do it like this in a Client Component: -export default function Page() { - return ( - - - - ) -} +```tsx +'use client'; +import { createStore, Provider } from 'jotai'; +import { useState } from 'react'; + +const Page = () => { + // Ensure a new store is created per request + const [store] = useState(() => createStore()); + return {/* your content */}; +}; ``` -> Note: `RenderingBoundary` is used in both React Server Components and React Client Components. `RenderingBoundary` itself is a React Client Component. +**jotai-ssr** offers a convenient `useCreateStore` hook to simplify this pattern: -`RenderingBoundary` creates a new store for subtree that is isolated from the parent store. For example, above `LayoutComponent` and `Component` will have their own store. In other words, the store is not shared between `layout.tsx` and `page.tsx`. +```tsx +'use client'; +import { Provider } from 'jotai'; +import { useCreateStore } from 'jotai-ssr'; -The store also independent for each request: the store will never be shared between requests. +const Page = () => { + const store = useCreateStore(); + return {/* your content */}; +}; +``` + +> **Note:** `useCreateStore` internally uses `useState`, so the component that calls `useCreateStore` must be a React Client Component (i.e., have the `'use client'` directive if you're in an RSC setup). -#### Sharing Stores between layout and page (Advanced Usage) -If you need to share stores between `layout.tsx` and `page.tsx`, you can use the `performanceImpactingUseUpperStore` option in `RenderingBoundary`, which will cause an additional re-render after initial hydration. +### Using the `SSRProvider` -#### Why do we need `RenderingBoundary`? -In Next.js, when navigating pages using the `Link` component, `layout.jsx` is not re-rendered, but `page.jsx` is. Because of this structure, if `page.jsx` references a Store that is in `layout.jsx` or is global, there's a possibility of errors occurring during page transitions due to mismatches between server-side rendered HTML and hydration. -Think following case: -1. Create `const sampleAtom = atom(0)` -2. Set up a Provider in `layout.jsx`, or render without setting up a Provider anywhere -3. Change sampleAtom to 1 in `page.jsx` -4. Navigate to another page using `Link` component +Alternatively, you can use the higher-level `SSRProvider` component from **jotai-ssr**. It can be used in either a React Client Component or a React Server Component: -In this case, the value of sampleAtom on server-side rendered HTML will be 0, but the value of sampleAtom on the client side will be 1. This will cause a mismatch between server-side rendered HTML and hydration, resulting in an error. To prevent this, use `RenderingBoundary` to create a new store for each page. +```tsx +import { SSRProvider } from 'jotai-ssr'; -#### How `performanceImpactingUseUpperStore` option works -Even if `performanceImpactingUseUpperStore` is set to `true`, `RenderingBoundary` will still create a new store for each request. However, after hydration, it will re-render the subtree once to use the store of the parent component. Because of this re-render, it may impact performance. +const Page = () => { + return {/* your content */}; +}; +``` -### Using SuspenseBoundary -**Only when the async component wrapped by `React.Suspense` subtree references atoms that have the potential to change while suspended**, use `SuspenseBoundary`. If there are no atoms that have the potential to change while suspended, you don't need to use `SuspenseBoundary`. +Internally, `SSRProvider` will create an isolated store for each request. You can also supply your own store: -```jsx -import { Suspense } from 'react' -import { SuspenseBoundary } from 'jotai-ssr' +```tsx +'use client'; +import { SSRProvider, useCreateStore } from 'jotai-ssr'; -function Component() { +const Page = () => { + const store = useCreateStore(); return ( - Loading...}> - - - - - ) -} + + {/* your content */} + + ); +}; ``` -> Note: `SuspenseBoundary` itself is React Server Component. -#### Why do we need `SuspenseBoundary`? -When using `React.Suspense`, if the component subtree references atoms that have the potential to change while suspended, there is a possibility of errors occurring during page transitions due to mismatches between server-side rendered HTML and hydration. To prevent this, use `SuspenseBoundary` to create a new store for the suspended subtree. +--- -### Hydrating Atoms -Use `HydrationBoundary` to hydrate atoms at specific points in your component tree. `hydrateAtoms` props is an array of atom and values to hydrate. +## Hydration -With React Server Components: -```jsx -import { HydrationBoundary } from 'jotai-ssr' -import { dataAtom } from './atoms' +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**. -async function Component() { - const data = await fetch('https://api.example.com/data').then((res) => res.json()) - return ( - - - - ) +### What is 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. + +### How to Hydrate an Atom + +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`](https://jotai.org/docs/utilities/ssr#usage), with a few small differences: + +1. **No** `dangerouslyForceHydrate` **option** +2. Exported from **`jotai-ssr`** rather than `jotai/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: + +```tsx +'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; } -``` -> Note: if you use `HydrationBoundary` in React Server Components, the file that defines a hydrated atom must include the `'use client'` directive, like this: -> ```jsx -> 'use client' -> -> import { atom } from 'jotai'; -> -> export const someAtom = atom(0); -> ``` -> Detailed explanation is [here](https://github.com/pmndrs/jotai/discussions/2692#discussioncomment-10316130). - -With React Client Components: -```jsx -'use client' - -import { HydrationBoundary } from 'jotai-ssr' -import { idAtom } from './atoms' - -function Component({ id }) { - return ( - - - - ) + +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
Count: {count}
; } ``` -You should use hydrated atoms within the `HydrationBoundary` component. If you use hydrated atoms outside of the `HydrationBoundary` component, it may cause mismatches between server-side rendered HTML and hydration. +Here’s what you need to know: + +1. **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). -You can use multiple `HydrationBoundary` components. -```jsx -import { HydrationBoundary } from 'jotai-ssr' -import { dataAAtom, dataBAtom } from './atoms' +2. **Optional `store` Parameter**: + Just like the Jotai version, you can target a specific store by providing the `store` option. For example: + ```tsx + import { createStore } from 'jotai'; -async function HydrationDataABoundary({ children }) { - const dataA = await fetch('https://api.example.com/dataA').then((res) => res.json()) + const myStore = createStore(); + useHydrateAtoms([[countAtom, 42]], { store: myStore }); + ``` + +3. **No `dangerouslyForceHydrate`**: + Unlike the original Jotai hook, the **jotai-ssr** version does **not** provide a `dangerouslyForceHydrate` option. If you need more advanced re-hydration behavior, see [Re-Hydration](#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. + +### `HydrationBoundary` + +For React Server Components (RSC) and for a clearer boundary-based approach, **jotai-ssr** provides a `HydrationBoundary` component: + +```tsx +import { HydrationBoundary } from 'jotai-ssr'; +import { countAtom } from './atoms'; // "use client" inside this file + +const ServerComponent = async () => { + const countFromServer = await fetchCount(); return ( - - {children} + + {/* Components that consume countAtom */} - ) -} + ); +}; +``` + +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 because `HydrationBoundary` itself is a React Client Component. + +--- -async function HydrationDataBBoundary({ children }) { - const dataB = await fetch('https://api.example.com/dataB').then((res) => res.json()) +## Important Notes on Hydration Logic + +### 1. Hydrate Before Using the Atom + +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**: +```tsx +const Component = ({ countFromServer }) => { + useHydrateAtoms([[countAtom, countFromServer]]); + const [count] = useAtom(countAtom); + return
{count}
; +}; +``` +or +```tsx +const Component = async ({ countFromServer }) => { return ( - - {children} + + - ) -} + ); +}; +``` -function Component() { +**Incorrect usage** (atom used before hydration): +```tsx +// Don't do this +const Component = ({ countFromServer }) => { + const [count] = useAtom(countAtom); + useHydrateAtoms([[countAtom, countFromServer]]); + return
{count}
; +}; +``` +or +```tsx +// Don't do this +const Component = async ({ countFromServer }) => { + const [count] = useAtom(countAtom); return ( -
- - - - - -
- ) -} + +
{count}
+
+ ); +}; ``` -#### Checking Hydration Status -You can use the `isHydratingAtom` to check if atom hydration is in progress: - -```js -import { atom } from 'jotai' -import { isHydratingAtom } from 'jotai-ssr' - -function Component() { - const samplePrimitiveAtom = atom(0) - - const sampleAtom = atom( - (get) => get(samplePrimitiveAtom), - (get, set, update) => { - set(samplePrimitiveAtom, update) - if (get(isHydratingAtom)) { - // Do something when atom is hydrating - } else { - // Do something when atom is not hydrating - } - } - ) -} -``` +### 2. Do Not Hydrate the Same Atom in Multiple Places Within the Same Provider + +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](https://jotai.org/docs/extensions/scope#jotai-scope) to scope them. + +### 3. Hydration Only Occurs on Initial Mount + +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. + +--- + +## Soft Navigation in SSR Frameworks + +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. + +--- + +## Re-Hydration + +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`: + + ```tsx + import { useHydrateAtoms } from 'jotai-ssr'; + + const Component = ({ countFromServer }) => { + useHydrateAtoms([[countAtom, countFromServer]], { enableReHydrate: true }); + const [count] = useAtom(countAtom); + return
{count}
; + }; + ``` + +- With `HydrationBoundary`: + + ```tsx + const ServerComponent = async () => { + const countFromServer = await fetchCount(); + return ( + + + + ); + }; + ``` + +When `enableReHydrate` is `true`, the component compares the new hydration values (via `Object.is`) and re-hydrates if they differ. + +### Route Refresh Considerations + +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. + +--- + +## License + +MIT License. See [LICENSE](./LICENSE) for details. + +--- + +## Feedback +This is a new package and we would love to hear your feedback. +Related discussion: https://github.com/pmndrs/jotai/discussions/2692 + +---