Talking about data hydration and persistent data in NextJS RSC/SSR II

Original link: https://innei.in/posts/programming/nextjs-rsc-ssr-data-hydration-persistence-two

This rendering is generated by marked, and there may be typography problems. For the best experience, please go to: https://innei.in/posts/programming/nextjs-rsc-ssr-data-hydration-persistence-two

Last time I talked about how to implement data hydration with React Query in SSR. In this issue, let’s talk about how to implement it with Jotai.

It will be more convenient to use Jotai to manage data and later modify data and respond to UI.

transform

inject data

In layout.tsx , we still use queryClient to obtain data, and then we need to pass the obtained data directly into Jotai’s atom. Later, the consumption data of the components on the client side are all obtained from atom. Probably 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} /> //确保第一位,需要在组件渲染之前完成注入{props.children} </> ) }

The above code is simple, I implemented a <CurrentNoteDataProvider /> component here, mainly to directly pour the data source of the current page into an atom. I will introduce the implementation of this Provider later.

Note that the difference from before is that we no longer use Hydrate provided by React Query to hydrate the Query, and the components on the Client side will no longer use useQuery to get data. Since the data is all in Jotai, the management of the data becomes very simple, and it is very convenient to filter what data you want.

Now the QueryClient on the server side can be a singleton instead of creating a new instance for each React Tree construction. Although creating a new instance can avoid cross-request data pollution, it cannot enjoy the benefits of Query cache . So now, we modify getQueryClient method.

 // query-client.server.ts const sharedClient = new QueryClient({ defaultOptions: { queries: { staleTime: 1000 * 3, cacheTime: 1000 * 3, }, }, }) export const getQueryClient = () => sharedClient

If you encounter data that needs to be authenticated, how to solve data pollution. It can be directly judged and processed in layout , whether or not the data needs to be injected into CurrentDataProvider . Or you can selectively inject data and only keep the 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-fieled']) // <---- 不会影响缓存的过滤return ( <> <CurrentNoteDataProvider data={filteredData} /> {props.children} </> ) }

consumption data

We come to a client-side component that needs to consume the data of the current page. Below is a simple component that displays the title of an article.

 export const NoteTitle = () => { const title = useCurrentNoteDataSelector((data) => data?.data.title) if (!title) return null return (

{title}


)
}

We also implemented a hook called useCurrentNoteDataSelector , which directly extracts the title field from the data just injected without consuming any other fields, and dynamically modifies the page data source later, which will be very convenient for fine-grained updates.

The same is true for any data that other components need to use, and fine-grained rendering can be achieved through Selector.

Implement Providers and Hooks

This is a general method, if more than one data source exists on the page, you can create multiple similar hooks.

So we create a factory function createDataProvider that batches these up.

 '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 host the page data source. Then CurrentDataProvider passes in a props named data , and it is very important to throw the data to currentDataAtom before the page is rendered. We need to make sure that the data in currentDataAtom is ready before rendering the page component. So we need useBeforeMounted to inject data synchronously. 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?.() } }

::: info

If the above code is used in the development environment, you may get a Warning telling you that you should not use setState directly in the render function.

 useBeforeMounted(() => { // React 会发出警告,但这是合理的,你可以忽略。 // 不要排斥这种方式,因为在新版React 文档中会告诉你善用这种方式去优化性能。 jotaiStore.set(currentDataAtom, data) })

:::

useCurrentDataSelector is relatively simple, that is, selectAtom provided by Jotai has a shell.

The above is the most basic creation method.

Now we create a CurrentNoteProvider .

 const { CurrentDataProvider, getCurrentData, setCurrentData, useCurrentDataSelector, } = createDataProvider<NoteWrappedPayload>()

very simple.

Responsively modify data

The advantage of Jotai is the separation of state and UI. With the above method, now we don’t need to pay too much attention to the UI update problem caused by data changes. We can drive UI updates by modifying data anywhere.

Now we have a Socket connection. When NOTE_UPDATE event is received, the data is updated immediately to drive the UI update. We can write 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) => { // <----- 直接修改数据Object.assign(draft.data, note) }) toast('手记已更新') } break } default: { if (isDev) { console.log(type, data) } } } }

After leaving the UI, it becomes very simple to modify the data. We directly obtain the data source inside the Jotai atom, judge whether it is consistent with the id in the event, and then directly update the data. We don’t need to care about the UI, as long as we use setCurrentNoteData to update the data, the UI will be updated immediately. And the move is very granular. Components that are not touched are never updated. You can see more in the file below.

https://github.com/Innei/sprightly/blob/14234594f44956e6f56f1f92952ce82db37ef4df/src/socket/handler.ts

data isolation

The above method is used to ensure the separation of data source and UI on the page. Now there is a new problem. Page components rely too much on the data of CurrentDataAtom , but there is only one CurrentDataAtom .

Now our page mainly has multiple components, assuming there are two NoteTitle , the first needs to display the title with NoteId 15, and the second needs to display the title with NoteId 16.

According to the above structure, this requirement is basically impossible to realize, because useCurrentDataSelector is used to obtain data in NoteTitle , but there is only one Atom.

0707002241.jpg

In order to solve this problem, we need to know three characteristics of React Context

  • When the component is not inside Provider , useContext will return a default value of Context, and this default value can be defined by us.
  • If it is inside Provider , the value passed into Provider will be consumed.
  • If it is inside the same Provider in multiple layers, the value of Provider closest to the component will be consumed.

To sum up, we can transform 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 newly added React Context, changed the original currentDataAtom to globalCurrentDataAtom for the top-level page data, and the top-level page data has the default value, that is, we don’t need to modify any original code. Adding Scope’s CurrentProvider can realize data isolation inside the component.

Now we need to wrap CurrentDataProvider for the second NoteTitle that needs to display data different from the current page.

0707003154.jpg

 // 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 ( <> // 确保第一位,需要在组件渲染之前完成注入// 这里我们不需要包括props.children <CurrentNoteDataProvider data={data} /> {props.children} </> ) } // page.tsx export default function Page() { return ( <> <NoteTitle /> // 这里需要包括,并传入不同的data <CurrentNoteDataProvider data={otherData}> <NoteTitle /> </CurrentNoteDataProvider> </> ) }

Based on the above method, data isolation can be achieved, and there are multiple data sources managed by Jotai on the page without interfering with each other.

Based on this feature, I stayed up all night to implement Shiro’s Peek function.

0707004422.gif

https://github.com/Innei/Shiro/commit/e1b0b57aaea0eec1b695c4f1961297b42b935044

OK, that’s all for today.

finish watching? say something

This article is transferred from: https://innei.in/posts/programming/nextjs-rsc-ssr-data-hydration-persistence-two
This site is only for collection, and the copyright belongs to the original author.