banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

A Brief Discussion on Data Hydration and Persistent Data in NextJS RSC/SSR (Part Two)

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.

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

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.

image

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 the Provider.
  • If inside multiple layers of the same Provider, it will consume the value of the nearest Provider.

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.

image

// 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.

image

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

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.