Search code examples
reactjstypescriptnext.js

Passing render props not working in next13


I'm trying to build a custom component for Contentful's live preview feature in Next 13 and the app directory. My idea is to create a client component which accepts a data prop, type-safe by allowing a generic type to be passed down. Like this:

LivePreviewWrapper.tsx

'use client'

import { useContentfulLiveUpdates } from '@contentful/live-preview/react'

const isFunction = <T extends CallableFunction = CallableFunction>(value: unknown): value is T =>
  typeof value === 'function'

export const runIfFunction = <T, U>(valueOrFn: T | ((...fnArgs: U[]) => T), ...args: U[]) => {
  return isFunction(valueOrFn) ? valueOrFn(...args) : valueOrFn
}

type MaybeRenderProp<P> = React.ReactNode | ((props: P) => React.ReactNode)

type LivePreviewWrapperProps<T> = {
  children: MaybeRenderProp<{
    updatedData: T
  }>
  data: T
}

export const LivePreviewWrapper = <T extends Record<string, unknown>>({
  children,
  data
}: LivePreviewWrapperProps<T>) => {
  const updatedData = useContentfulLiveUpdates<T>(data, { locale: 'en-US' })

  return runIfFunction(children, { updatedData })
}

I can then pull this into a page component, pass the data I pull from Contentful down, include the type, and then get the live updates. But, when I try and run it this way in a page component, I'm getting this error:

Error: Functions cannot be passed directly to Client Components unless you explicitly expose it by marking it with "use server".
  <... data={{...}} children={function}>
                             ^^^^^^^^^^

This is what my page component looks like:

import { notFound } from 'next/navigation'

import { ContentfulCta, type ContentfulCtaProps } from '~/components/contentful/cta'
import { ContentfulGrid, type ContentfulGridProps } from '~/components/contentful/grid'
import { ContentfulHero, type ContentfulHeroProps } from '~/components/contentful/hero'
import { ContentfulHighlight, type ContentfulHighlightProps } from '~/components/contentful/highlight'
import { ContentfulHorizontalTabs, type ContentfulHorizontalTabsProps } from '~/components/contentful/horizontal-tabs'
import { ContentfulSpeaker, type ContentfulSpeakerProps } from '~/components/contentful/speaker'
import { ContentfulTextSession, type ContentfulTextSessionProps } from '~/components/contentful/text'
import { LogoList } from '~/components/marketing/logo-list'
import { LivePreviewWrapper } from '~/components/shared/live-preview-wrapper'
import { getContentfulParams, getPageBySlug, type ContentfulPage } from '~/models/contentful'
import { ContentfulProvider } from '~/providers/contentful'
import { type TypePageSkeleton } from '~/types/generated/contentful'
import { type PageParamsWithSearch } from '~/types/helpers'
import { formatParamsSlug } from '~/utils/core'

import '@contentful/live-preview/style.css'

type LandingPageParams = PageParamsWithSearch<{ slug: string[] }>

export const generateStaticParams = async () => {
  const pages = await getContentfulParams<TypePageSkeleton>('page')

  return pages.items.map((page) => ({
    params: {
      slug: formatParamsSlug(page.fields.slug)
    }
  }))
}

export const revalidate = 60
export const dynamic = 'force-dynamic'

export default async function ContentfulLandingPage({ params, searchParams }: LandingPageParams) {
  const isDraftMode = searchParams?.draftMode === 'enabled'
  const data = await getPageBySlug({ slug: params.slug, isDraftMode })

  if (!data) {
    notFound()
  }

  return (
    <ContentfulProvider>
      <main className='pb-8 lg:pb-12'>
        <LivePreviewWrapper<ContentfulPage> data={data}>
          {({ updatedData }) => {
            return (
              <div className='space-y-8'>
                {updatedData.fields.body.map((entry) => {
                  if (!entry) return null
                  switch (entry.sys.contentType.sys.id) {
                    case 'hero': {
                      return <ContentfulHero key={entry.sys.id} entry={entry as ContentfulHeroProps} />
                    }

                    case 'highlightSession': {
                      return <ContentfulHighlight key={entry.sys.id} entry={entry as ContentfulHighlightProps} />
                    }

                    case 'grid': {
                      return <ContentfulGrid key={entry.sys.id} entry={entry as ContentfulGridProps} />
                    }

                    case 'textSession': {
                      return <ContentfulTextSession key={entry.sys.id} entry={entry as ContentfulTextSessionProps} />
                    }

                    case 'speaker': {
                      return <ContentfulSpeaker key={entry.sys.id} entry={entry as ContentfulSpeakerProps} />
                    }

                    case 'cta': {
                      return <ContentfulCta key={entry.sys.id} entry={entry as ContentfulCtaProps} />
                    }

                    case 'horizontalTabs': {
                      return (
                        <ContentfulHorizontalTabs key={entry.sys.id} entry={entry as ContentfulHorizontalTabsProps} />
                      )
                    }

                    case 'customersLogos': {
                      return <LogoList key={entry.sys.id} />
                    }

                    default:
                      console.warn(entry.sys.contentType, 'was not handled')
                      return null
                  }
                })}
              </div>
            )
          }}
        </LivePreviewWrapper>
      </main>
    </ContentfulProvider>
  )
}

I don't understand why this error is being returned; does anyone have a better idea how to fix it, I don't know what else to try. It seems to be something with the useContentfulLiveUpdates hook, but the type of updatedData is an object.


Solution

  • Functions are not serialisable in Next.js at this stage. Render props work RSC -> RSC or RCC -> RCC but not RSC -> RCC yet it seems. In this case, it looks to me that the compiler could easily be made to be smart enough to handle this case but I think Next.js 13 app dir and RSC need some time to mature.

    It would appear to me that creating an intermediary client component would work.

    "use client"
    // ... 
    
    export const MyLivePreview = ({ data }: { data: WhateverDataType }) => <LivePreviewWrapper<ContentfulPage> data={data}>
              {({ updatedData }) => {
                return (
                  <div className='space-y-8'>
                    {updatedData.fields.body.map((entry) => {
                      if (!entry) return null
                      switch (entry.sys.contentType.sys.id) {
                        case 'hero': {
                          return <ContentfulHero key={entry.sys.id} entry={entry as ContentfulHeroProps} />
                        }
    
                        case 'highlightSession': {
                          return <ContentfulHighlight key={entry.sys.id} entry={entry as ContentfulHighlightProps} />
                        }
    
                        case 'grid': {
                          return <ContentfulGrid key={entry.sys.id} entry={entry as ContentfulGridProps} />
                        }
    
                        case 'textSession': {
                          return <ContentfulTextSession key={entry.sys.id} entry={entry as ContentfulTextSessionProps} />
                        }
    
                        case 'speaker': {
                          return <ContentfulSpeaker key={entry.sys.id} entry={entry as ContentfulSpeakerProps} />
                        }
    
                        case 'cta': {
                          return <ContentfulCta key={entry.sys.id} entry={entry as ContentfulCtaProps} />
                        }
    
                        case 'horizontalTabs': {
                          return (
                            <ContentfulHorizontalTabs key={entry.sys.id} entry={entry as ContentfulHorizontalTabsProps} />
                          )
                        }
    
                        case 'customersLogos': {
                          return <LogoList key={entry.sys.id} />
                        }
    
                        default:
                          console.warn(entry.sys.contentType, 'was not handled')
                          return null
                      }
                    })}
                  </div>
                )
              }}
            </LivePreviewWrapper>
    

    Then,

    export default async function ContentfulLandingPage({ params, searchParams }: LandingPageParams) {
      const isDraftMode = searchParams?.draftMode === 'enabled'
      const data = await getPageBySlug({ slug: params.slug, isDraftMode })
    
      if (!data) {
        notFound()
      }
    
      return (
        <ContentfulProvider>
          <main className='pb-8 lg:pb-12'>
            <MyLivePreview data={data} />
          </main>
        </ContentfulProvider>
      )
    }
    

    Annoyingly, for my experience using Next.js 13 app dir, a lot of code splitting is required. I would imagine the compiler will become smarter to know what server and what's client.

    Let me know if this helps (noting that I did not actually test any of this). Otherwise will be happy to help to diagnose the issue.