Last time we talked about how to achieve data hydration in SSR using React Query. This time, let's discuss how to implement it using Jotai.
Using Jotai for data management makes it easier to modify data later and respond to UI changes.
Transformation#
Injecting Data#
In layout.tsx
, we still use queryClient
to fetch data, and then we need to directly pass the fetched data into Jotai's atom. Later, all client-side components will consume data from the atom. It looks something like this:
// layout.tsx
export default async (
props: NextPageParams<{
id: string
}>,
) => {
const id = props.params.id
const query = queries.note.byNid(id)
const data = await getQueryClient().fetchQuery(query)
return (
<>
<CurrentNoteDataProvider data={data} /> //
Ensure this is the first step, data injection must be completed before component rendering
{props.children}
</>
)
}
The code above is simplified; I implemented a <CurrentNoteDataProvider />
component that directly injects the current page's data source into an atom. I will introduce the implementation of this Provider later.
Note that, unlike before, we no longer use the Hydrate
provided by React Query for Query hydration; client-side components will no longer use useQuery
to fetch data. Since all data is in Jotai, managing data becomes very simple, and filtering data becomes very convenient.
Now the server-side QueryClient can be a singleton, rather than creating a new instance for each React Tree build. Creating new instances can avoid cross-request data pollution, but it cannot enjoy the benefits of Query caching. So now, we modify the getQueryClient
method.
// query-client.server.ts
const sharedClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 3,
cacheTime: 1000 * 3,
},
},
})
export const getQueryClient = () => sharedClient
If there is data that requires authentication, how do we solve data pollution? We can directly judge and handle in layout
whether to inject data into CurrentDataProvider
. Alternatively, we can selectively inject data, keeping only public content.
// layout.tsx
export default async (
props: NextPageParams<{
id: string
}>,
) => {
const id = props.params.id
const query = queries.note.byNid(id)
const data = await getQueryClient().fetchQuery(query)
const filteredData = omit(data, ['some-private-field']) // <---- Filtering won't affect the cache
return (
<>
<CurrentNoteDataProvider data={filteredData} />
{props.children}
</>
)
}
Consuming Data#
We come to a client-side component that needs to consume the current page's data. Here is a simple component to display the title of the article.
export const NoteTitle = () => {
const title = useCurrentNoteDataSelector((data) => data?.data.title)
if (!title) return null
return (
<h1 className="mt-8 text-left font-bold text-base-content/95">{title}</h1>
)
}
We also implemented a hook called useCurrentNoteDataSelector
, which directly extracts the title field from the injected data without consuming any other fields. This makes it very convenient to dynamically modify the page's data source and achieve fine-grained updates.
Other components that need to use any data can also achieve fine-grained rendering through Selectors.
Implementing Provider and Hooks#
This is a general method; if there is more than one data source on the page, multiple similar hooks can be created.
So we create a factory function createDataProvider
to batch produce these.
'use client'
import { memo, useCallback, useEffect } from 'react'
import { produce } from 'immer'
import { atom, useAtomValue } from 'jotai'
import { selectAtom } from 'jotai/utils'
import type { FC, PropsWithChildren } from 'react'
import { useBeforeMounted } from '~/hooks/common/use-before-mounted'
import { noopArr } from '~/lib/noop'
import { jotaiStore } from '~/lib/store'
export const createDataProvider = <Model>() => {
const currentDataAtom = atom<null | Model>(null)
const CurrentDataProvider: FC<
{
data: Model
} & PropsWithChildren
> = memo(({ data, children }) => {
useBeforeMounted(() => {
jotaiStore.set(currentDataAtom, data)
})
useEffect(() => {
jotaiStore.set(currentDataAtom, data)
}, [data])
useEffect(() => {
return () => {
jotaiStore.set(currentDataAtom, null)
}
}, [])
return children
})
CurrentDataProvider.displayName = 'CurrentDataProvider'
const useCurrentDataSelector = <T>(
selector: (data: Model | null) => T,
deps?: any[],
) => {
const nextSelector = useCallback((data: Model | null) => {
return data ? selector(data) : null
}, deps || noopArr)
return useAtomValue(selectAtom(currentDataAtom, nextSelector))
}
const setCurrentData = (recipe: (draft: Model) => void) => {
jotaiStore.set(
currentDataAtom,
produce(jotaiStore.get(currentDataAtom), recipe),
)
}
const getCurrentData = () => {
return jotaiStore.get(currentDataAtom)
}
return {
CurrentDataProvider,
useCurrentDataSelector,
setCurrentData,
getCurrentData,
}
}
In the factory function, we first create a basic currentDataAtom
. This atom is used to manage the page's data source. Then CurrentDataProvider
takes a prop called data
, and it is crucial to throw the data into currentDataAtom
before the page renders. We need to ensure that the data in currentDataAtom
is ready before rendering the page component. Therefore, we need useBeforeMounted
to achieve synchronous data injection. The implementation is as follows:
// use-before-mounted.ts
import { useRef } from 'react'
export const useBeforeMounted = (fn: () => any) => {
const effectOnce = useRef(false)
if (!effectOnce.current) {
effectOnce.current = true
fn?.()
}
}
::: banner info
The above code may produce a warning in the development environment, telling you that you should not directly use setState
in the render function.
useBeforeMounted(() => {
// React will issue a warning, but this is reasonable, and you can ignore it.
// Don't dismiss this approach, as the new React documentation will tell you to use this method to optimize performance.
jotaiStore.set(currentDataAtom, data)
})
:::
useCurrentDataSelector
is quite simple; it is just a wrapper around Jotai's selectAtom
.
This is the most basic creation method.
Now we create a CurrentNoteProvider
.
const {
CurrentDataProvider,
getCurrentData,
setCurrentData,
useCurrentDataSelector,
} = createDataProvider<NoteWrappedPayload>()
Very simple.
Reactive Data Modification#
The benefit of Jotai is the separation of state and UI. With the above methods, we no longer need to worry about UI updates caused by data changes. We can drive UI updates by modifying data anywhere.
Now we have a Socket connection, and when we receive a NOTE_UPDATE
event, we immediately update the data, driving the UI to update. We can write it like this.
// event-handler.ts
import {
getCurrentNoteData,
setCurrentNoteData,
} from '~/providers/note/CurrentNoteDataProvider'
import { EventTypes } from '~/types/events'
export const eventHandler = (
type: EventTypes,
data: any,
router: AppRouterInstance,
) => {
switch (type) {
case 'NOTE_UPDATE': {
const note = data as NoteModel
if (getCurrentNoteData()?.data.id === note.id) {
setCurrentNoteData((draft) => {
// <----- Directly modify data
Object.assign(draft.data, note)
})
toast('Note has been updated')
}
break
}
default: {
if (isDev) {
console.log(type, data)
}
}
}
}
After separating from the UI, modifying data becomes very simple. We directly access the data source inside the Jotai atom, check if the id matches the one in the event, and then update the data directly. We don't need to worry about how the UI behaves; we just use setCurrentNoteData
to update the data, and the UI will update immediately. Moreover, this action is very granular. Components that are not involved will never update. You can check the following file for more.
Data Isolation#
Using the above methods ensures the separation of data sources and UI on the page. Now there is a new problem. Page components are overly dependent on the CurrentDataAtom
, but there is only one CurrentDataAtom
.
Now our page mainly consists of multiple components. Suppose there are two NoteTitle
components, one needs to display the title for NoteId 15, while the other needs to display the title for NoteId 16.
Based on the above structure, this requirement is basically impossible to achieve because both NoteTitle
components use useCurrentDataSelector
to fetch data, while the atom is only one.
To solve this problem, we need to know three characteristics of React Context:
- When a component is not inside a
Provider
,useContext
will return a default value for the Context, which we can define. - If inside a
Provider
, it will consume the value passed to theProvider
. - If inside multiple layers of the same
Provider
, it will consume the value of the nearestProvider
.
In summary, we can modify CurrentDataProvider
.
// createDataProvider.tsx
export const createDataProvider = <Model,>() => {
const CurrentDataAtomContext = createContext(
null! as PrimitiveAtom<null | Model>,
)
const globalCurrentDataAtom = atom<null | Model>(null)
const CurrentDataAtomProvider: FC<
PropsWithChildren<{
overrideAtom?: PrimitiveAtom<null | Model>
}>
> = ({ children, overrideAtom }) => {
return (
<CurrentDataAtomContext.Provider
value={overrideAtom ?? globalCurrentDataAtom}
>
{children}
</CurrentDataAtomContext.Provider>
)
}
const CurrentDataProvider: FC<
{
data: Model
} & PropsWithChildren
> = memo(({ data, children }) => {
const currentDataAtom =
useContext(CurrentDataAtomContext) ?? globalCurrentDataAtom
useBeforeMounted(() => {
jotaiStore.set(currentDataAtom, data)
})
useEffect(() => {
jotaiStore.set(currentDataAtom, data)
}, [data])
useEffect(() => {
return () => {
jotaiStore.set(currentDataAtom, null)
}
}, [])
return children
})
CurrentDataProvider.displayName = 'CurrentDataProvider'
const useCurrentDataSelector = <T,>(
selector: (data: Model | null) => T,
deps?: any[],
) => {
const currentDataAtom =
useContext(CurrentDataAtomContext) ?? globalCurrentDataAtom
const nextSelector = useCallback((data: Model | null) => {
return data ? selector(data) : null
}, deps || noopArr)
return useAtomValue(selectAtom(currentDataAtom, nextSelector))
}
const useSetCurrentData = () =>
useSetAtom(useContext(CurrentDataAtomContext) ?? globalCurrentDataAtom)
const setGlobalCurrentData = (recipe: (draft: Model) => void) => {
jotaiStore.set(
globalCurrentDataAtom,
produce(jotaiStore.get(globalCurrentDataAtom), recipe),
)
}
const getGlobalCurrentData = () => {
return jotaiStore.get(globalCurrentDataAtom)
}
return {
CurrentDataAtomProvider,
CurrentDataProvider,
useCurrentDataSelector,
useSetCurrentData,
setGlobalCurrentData,
getGlobalCurrentData,
}
}
We added React Context, changing the original currentDataAtom
to globalCurrentDataAtom
for top-level page data. The top-level page data receives a default value, meaning we do not need to modify any of the original code. The added scoped CurrentProvider
allows for data isolation within components.
Now, for the second NoteTitle
that needs to display different data from the current page, we just need to wrap it with CurrentDataProvider
.
// layout.tsx
export default async (
props: NextPageParams<{
id: string
}>,
) => {
const id = props.params.id
const query = queries.note.byNid(id)
const data = await getQueryClient().fetchQuery(query)
return (
<>
// Ensure this is the first step, data injection must be completed before component rendering // We don't need to include this here
props.children
<CurrentNoteDataProvider data={data} />
{props.children}
</>
)
}
// page.tsx
export default function Page() {
return (
<>
<NoteTitle />
// Here we need to include it and pass different data
<CurrentNoteDataProvider data={otherData}>
<NoteTitle />
</CurrentNoteDataProvider>
</>
)
}
Based on the above methods, data isolation can be achieved, allowing multiple data sources managed by Jotai on the page to operate independently.
Based on this feature, I stayed up late to implement the Peek feature for Shiro.
https://github.com/Innei/Shiro/commit/e1b0b57aaea0eec1b695c4f1961297b42b935044
OK, that's all for today.
This article was synchronized and updated to xLog by Mix Space. The original link is https://innei.in/posts/programming/nextjs-rsc-ssr-data-hydration-persistence-two