prismicio / prismic-react

React components and hooks to fetch and present Prismic content
https://prismic.io/docs/technologies/homepage-reactjs
Apache License 2.0
154 stars 40 forks source link

RFC: Generic React SliceZone component #86

Closed angeloashmore closed 2 years ago

angeloashmore commented 3 years ago

RFC: Generic React SliceZone component

In this RFC, I propose a generic React SliceZone component be created and used in all React, Gatsby, and Next.js projects using Prismic Slice Zones.

<SliceZone
  slices={doc.data.body}
  components={{
    foo: FooSlice,
    bar: BarSlice,
  }}
/>

Creating a standard component leads to a consistent API design across framework integrations. It also leads to consistent and shared education efforts.

Consider the following existing implementations of Slice Zone components when reading this RFC to understand how the packages could be compared and integrated:

A general SliceZone component must have the following attributes:

To achieve this, clear boundries of this component's functionality must be set.

What this component will do:

What this component will not do:

Example implementation

The following example implementation makes use of the new experimental @prismicio/types library. The library contains shared TypeScript types and interfaces that can be used across TypeScript projects. In this example, we're using it for its generic Slice type.

import * as React from 'react'
import * as prismicT from '@prismicio/types'

type IterableElement<TargetIterable> = TargetIterable extends Iterable<
  infer ElementType
>
  ? ElementType
  : never

export interface SliceComponentProps<
  Slice extends prismicT.Slice = prismicT.Slice,
  TContext = unknown
> {
  slice: Slice
  context?: TContext
}

export type SliceZoneProps<TSlices extends prismicT.SliceZone, TContext> = {
  slices: TSlices
  components: Partial<
    Record<
      IterableElement<TSlices>['slice_type'],
      React.ComponentType<SliceComponentProps>
    >
  >
  defaultComponent?: React.ComponentType<SliceComponentProps>
  context?: TContext
}

const MissingSlice = ({ slice }: SliceComponentProps) => (
  <span>Could not find a component for Slice type {slice.slice_type}</span>
)

export const SliceZone = <TSlices extends prismicT.SliceZone, TContext>({
  slices,
  components,
  defaultComponent = MissingSlice,
  context,
}: SliceZoneProps<TSlices, TContext>) => {
  return (
    <>
      {slices.map((slice) => {
        const Comp = components[slice.slice_type] || defaultComponent

        return <Comp slice={slice} context={context} />
      })}
    </>
  )
}

Items of note:

Example Slice component

Like above, we are using @prismicio/types for its generic Slice type and collection of Prismic field types.

It references the SliceComponentProps interface defined in the above example.

import * as React from 'react'
import * as prismicT from '@prismicio/types'
import { RichText } from 'prismic-reactjs'

type FooSliceProps = SliceComponentProps<
  prismicT.Slice<
    'foo',
    {
      title: prismicT.TitleField
      number: prismicT.NumberField
    }
  >
>

export const FooSlice = ({ slice }: FooSliceProps) => (
  <RichText render={slice.primary.title} />
)

type BarSliceProps = SliceComponentProps<
  prismicT.Slice<
    'bar',
    {
      geopoint: prismicT.GeoPointField
      embed: prismicT.EmbedField
    }
  >
>

export const BarSlice = ({ slice }: BarSliceProps) => (
  <span>{slice.primary.geopoint.latitude}</span>
)

Component flexibility

In the previous example, SliceComponentProps is used to type the components. Slice component do not need to adhere to this interface. It is, however, what will be passed to the provided component as part of SliceZone's rendering process.

To use a different prop interface for the Slice component, one can adjust the component map provided to SliceZone.

<SliceZone
  slices={doc.data.body}
  components={{
    foo: ({ slice, context }) => (
      <FooSlice foo={RichText.asText(slice.primary.title)} bar={context.baz} />
    ),
    bar: BarSlice,
  }}
/>

If such a behavior becomes common within an application, an abstraction can be created. Ideally this abstraction is created by a user, not provided by the library.

// src/slices/Foo.tsx

import * as React from 'react'
import * as prismicT from '@prismicio/types'
import { RichText } from 'prismic-reactjs'

import { Context } from '../types'

type FooSliceProps = {
  foo: string
  bar: number
}

const FooSlice = ({ foo, bar }: FooSliceProps) => (
  <h1>{bar === 10 ? foo : 'not 10'}</h1>
)

export default FooSlice

type FooSliceData = prismicT.Slice<
  'foo',
  {
    title: prismicT.TitleField
    number: prismicT.NumberField
  }
>

export const mapSliceToProps = ({
  slice,
  context,
}: SliceComponentProps<FooSliceData, Context>) => ({
  foo: RichText.asText(slice.primary.title),
  bar: context.baz,
})
// src/App.tsx

import * as React from 'react'

import * as FooSlice from './slices/Foo'
import * as BarSlice from './slices/Bar'

type WithSliceZoneMapperModule = {
  default: React.ComponentType
  mapSliceToProps?: (SliceComponentProps) => Record<string, unknown>
}

const withSliceZoneMapper = ({
  default: Comp,
  mapSliceToProps,
}: WithSliceZoneMapperModule) => (sliceComponentProps: SliceComponentProps) => {
  if (mapSliceToProps) {
    return <Comp {...mapSliceToProps(sliceComponentProps)} />
  }

  return <Comp {...sliceComponentProps} />
}

export const App = () => (
  <SliceZone
    slices={doc.data.body}
    components={{
      foo: withSliceZoneMapper(FooSlice),
      bar: BarSlice,
    }}
    context={{ baz: 10 }}
  />
)

Generatable

To be viable, this component must work with generated code. Inversely, the code required by the component must be easy to generate. These requirements come from the need to integrate this component with Slice Machine.

  1. Use higher-level types from @prismicio/types to simplify generated types.
  2. Organize component exports systematically (for example, all Slices could be imported and exported through one file).
  3. Include as little code as possible in generated code.
  4. Perform as little magic as possible in generated code.
  5. Don't make a black box. Simplify instead.

Final comments

This general component is low-level enough to be used in any React project using Prismic. It does not require TypeScript usage, but users will benefit greatly through its use.

Topics like default props (linked issue is about Vue.js but applies here as well) become something that can be handled via React, not the Slice Zone component.

None of the code presented here has been tested or run. Please don't paste this into a file and wonder why it doesn't work. 😅 This is all open for discussion!

a-trost commented 3 years ago

This looks fantastic, Angelo. I definitely agree with the need and reasoning behind implementing this. The implementation looks great, too. I'm all for it.

Duaner commented 2 years ago

Hey @angeloashmore the only suggestion that I would have is to consider fetching data with the slice zone, in term of vision we think this can fit most of the use cases and simplify the overall development experience for newcomers. You drop a slice-zone and then focus on your components.

angeloashmore commented 2 years ago

Thanks for the review @Duaner.

I see this as a low-level component that does not implement data fetching because data fetching usually requires nuanced implementations. I think we would build integrations around this component in order to fulfill the vision you describe (and I agree with!).

For example, to take full advantage of Next.js, data fetching needs to happen in getStaticProps, getServerSideProps, or getInitialProps. If the component did data fetching, it would only happen in the browser at run time, not the server. This integration would be in something like a new @prismicio/next kit, or directly in Slice Machine's Next.js package.

We have a similar issue with static site generators like Gatsby. Data fetching is done in a custom GraphQL API implemented by the framework. This allows Gatsby to ensure build-time data is properly split between pages for faster loading. Again, if the component fetched its own data, it would only happen in the browser at run time. In this specific case, there also isn't a way to automatically fetch data from Gatsby's GraphQL API from within a component. There may not be a kit created for Gatsby, but rather a shared best practice on structuring the project using this SliceZone component.

I think we have to look at specific use-cases to learn how to implement this component and the ecosystem around it. I've started to do that. I can create a quick diagram showing the overall architecture of the JavaScript packages and how they relate to each other, all with a focus on simplifying, but also being explicit.

angeloashmore commented 2 years ago

How could the <SliceZone> component fit into React and its higher-level frameworks like Next.js and Gatsby?

Read on…

Things to keep in mind

  1. Users may have expectations of how data should flow in their applications. They know where to fetch data. They know where to display data.

  2. If users don't know how their framework works, we should teach them how to best use it. We should promote sustainable practices for long-term success and satisfaction. This involves working within a framework's conventions.

  3. Well thought out lower-level APIs have been developed. We should use them. There isn't a need to re-implement simpler APIs if the underlying libraries are already simple.

General concept

  1. Fetch a document from an API. The document includes Slice Zones and any document-level fields.

    The method with which the document is fetched varies by framework. Some require specialized methods for optimization purposes, such as server side rendering.

    API requests can be performed in any way, whether through the REST API, GraphQL API, or through client libraries, like @prismicio/client. We would recommend the best method for the framework.

  2. Render that document. Full access to the document's data is necessary to access document-level fields. This is needed for setting content such as SEO data, page titles, and redirects. Slices are just one part of the document.

    That being said, Slices are arguably the most important part of the document. As such, rendering that content should be easy.

image

Implementations

The Slice Zone React component is designed to work in any React app. This includes create-react-app, Next.js, and Gatsby.

How a user uses it, however, will be framework-specific due to different data fetching requirements. Next.js users, for example, will fetch documents via getStaticProps where Gatsby users will query its internal GraphQL API.

The following diagram shows how data would flow through different frameworks and libraries.

image

For the following examples, assume the following components and types are available.

Show/hide code ```typescript // types.ts import * as prismicT from '@prismicio/types' export type PrismicPage = prismicT.PrismicDocument< { title: prismicT.RichTextField body: prismicT.SliceZone< | PrismicPageBodyTextSlice | PrismicPageBodyImageGallerySlice | PrismicPageBodyQuoteSlice > }, 'page', 'en-us' | 'fr-fr' > export type PrismicPageBodyTextSlice = prismicT.Slice< 'text', { text: prismicT.RichTextField } > export type PrismicPageBodyImageGallerySlice = prismicT.Slice< 'text', never, { image: prismicT.ImageField caption: prismicT.KeyTextField } > export type PrismicPageBodyQuoteSlice = prismicT.Slice< 'quote', { quote: prismicT.RichTextField quotee: prismicT.KeyTextField } > ``` ```typescript // components/PageBodyText.ts import { RichText } from '@prismicio/react' import { PrismicPageBodyTextSlice } from '../types' type PageBodyTextProps = { slice: PrismicPageBodyTextSlice } export const PageBodyText = ({ slice }: PageBodyTextProps) => { return (
) } ``` ```typescript // components/PageBodyImageGallery.ts import { RichText } from '@prismicio/react' import { PrismicPageBodyImageGallerySlice } from '../types' type PageBodyTextProps = { slice: PrismicPageBodyImageGallerySlice } export const PageBodyImageGallery = ({ slice }: PageBodyImageGalleryProps) => { return (
    {slice.items.map((item) => (
  • {item.image.alt} {item.caption &&
    {item.caption}
    }
  • ))}
) } ``` ```typescript // components/PageBodyQuote.ts import { RichText } from '@prismicio/react' import { PrismicPageBodyQuoteSlice } from '../types' type PageBodyTextProps = { slice: PrismicPageBodyQuoteSlice } export const PageBodyQuote = ({ slice }: PageBodyQuoteProps) => { return (
{slice.primary.quotee}
) } ```

Next.js

getStaticProps and getServerSideProps are Next.js's recommended data fetching APIs. Any server-side code can be called here that returns an object which will be passed to the page. Fetching data in these APIs ensures data is not fetched during client-side rendering, delivering a faster experience for users.

In the following code example, getStaticProps is used to fetch a document and pass it to the page. It is fully typed using TypeScript with @prismicio/types.

import * as nextT from 'next/types'
import * as prismicNext from '@prismicio/next'
import * as prismicH from '@prismicio/helpers'
import { SliceZone } from '@prismicio/react'

import { PrismicPage } from '../types'
import { createClient } from '../prismic'

import { PageBodyText } from '../components/PageBodyText'
import { PageBodyImageGallery } from '../components/PageBodyImageGallery'
import { PageBodyQuote } from '../components/PageBodyQuote'

type PageParams = {
  uid: string
}

type PageProps = {
  page: PrismicPage
}

export default function Page({ page }: PageProps) {
  return (
    <>
      <Head>
        <title>{prismicH.asText(page.data.title)}</title>
      </Head>
      <SliceZone
        slices={page.data.body}
        components={{
          text: PageBodyText,
          image_gallery: PageBodyImageGallery,
          quote: PageBodyQuote,
        }}
      />
    </>
  )
}

export const getStaticProps: GetStaticProps<PageProps, PageParams> = async (
  context,
) => {
  // Assume createClient() returns a pre-configured @prismicio/client instance.
  const client = createClient()
  prismicNext.enableClientServerSupportFromContext(client, context)

  const uid = context.params.uid
  const page = await client.getByUID<PrismicPage>('page', uid)

  return {
    props: { page },
  }
}

Gatsby

Gatsby requires a unique approach to data fetching. It aggregates data from multiple sources configured by a user and exposes it as an internal GraphQL API. It has a strong emphasis on static site generation and encourages users to perform work during build-time.

In the following code example, query is used to fetch a document and pass it to the page. It is fully typed using TypeScript with a code generator like graphql-code-generator.

Notice that the component is nearly identical to Next.js. The example differs primarily in its data fetching, but follows the same concept.

import * as React from 'react'
import { graphql, PageProps } from 'gatsby'
import { Helmet } from 'react-helmet-async'
import { SliceZone } from '@prismicio/react'

import { PageTemplateQuery } from '../types'

import { PageBodyText } from '../components/PageBodyText'
import { PageBodyImageGallery } from '../components/PageBodyImageGallery'
import { PageBodyQuote } from '../components/PageBodyQuote'

type PageContext = {
  id: string
}

type PageProps = PageProps<PageTemplateQuery, PageContext>

export default function Page({ data }: PageProps) {
  const page = data.prismicPage

  return (
    <>
      <Helmet>
        <title>{page.data.title.text}</title>
      </Helmet>
      <SliceZone
        slices={page.data.body}
        components={{
          text: PageBodyText,
          image_gallery: PageBodyImageGallery,
          quote: PageBodyQuote,
        }}
      />
    </>
  )
}

export const query = graphql`
  query PageTemplate($id: String!) {
    prismicPage(id: { eq: $id }) {
      data {
        title {
          text
        }
        body {
          ... on PrismicPageDataText {
            primary {
              text {
                raw
              }
            }
          }
          ... on PrismicPageDataImageGallery {
            items {
              image {
                url
                alt
              }
              caption
            }
          }
          ... on PrismicPageDataQuote {
            primary {
              quote {
                raw
              }
              quotee
            }
          }
        }
      }
    }
  }
`

React

React is the underlying UI layer for Next.js and Gatsby. Many users choose to use it directly.

In the following code example, usePrismicClient is used to fetch a document and pass it to the return value of the component. It is fully typed using TypeScript with @prismicio/types.

This example could be futher developed with Suspense for proper data loading, but it should illustrate the SliceZone component sufficiently.

import * as React from 'react'
import * as prismicH from '@prismicio/helpers'
import { SliceZone } from '@prismicio/react'

import { PrismicPage } from '../types'
import { client } from '../prismic'

import { PageBodyText } from '../components/PageBodyText'
import { PageBodyImageGallery } from '../components/PageBodyImageGallery'
import { PageBodyQuote } from '../components/PageBodyQuote'

export default function Page() {
  const [page, setPage] = React.useState<PrismicPage>()

  useEffect(() => {
    const asyncFn = async () => {
      const uid = 'uid' // Get the page UID somehow
      const document = await client.getByUID<PrismicPage>('page', uid)
      setPage(document)
    }
    asyncFn()
  }, [])

  if (!page) {
    return <span>Loading</span>
  }

  return (
    <>
      <Head>
        <title>{prismicH.asText(page.data.title)}</title>
      </Head>
      <SliceZone
        slices={page.data.body}
        components={{
          text: PageBodyText,
          image_gallery: PageBodyImageGallery,
          quote: PageBodyQuote,
        }}
      />
    </>
  )
}
angeloashmore commented 2 years ago

@prismicio/react@v2.0.0 was published with the <SliceZone> described above (see #97) so I'm going to close this issue.

Thanks for your feedback, everyone!