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:
- Ability to render a list of Slices from a Prismic document.
- A method with which users can determine which component gets rendered for a Slice.
- Be easily customized and extended to fit a user's specific needs.
- Follow idiomatic React practices.
- Ability to integrate in "vanilla" React, Gatsby, and Next.js (including Slice Machine) projects.
- Usage can be programmatically generated (think: Slice Machine or CLI integration).
- Have deep TypeScript integration for type safety.
To achieve this, clear boundries of this component's functionality must be set.
What this component will do:
- Accept a list of Slice objects
- Accept a collection of React components
- Render a React component for each Slice
What this component will not do:
- Create network requests for Prismic content
- Have a strict opinion on how a Slice's React component is implemented
- Integrate deeply with a framework (framework-specific abstractions can exist within a framework-specific kit)
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:
-
SliceComponentProps
is the normalized React props interface for all Slice React components. It contains the full Slice data object and an optional user-defined context value. The context value can contain anything a user needs to send down to a Slice's component.
-
SliceZoneProps
is properly typed, but not too strictly. It accepts a list of Slice objects, a record of Slice types mapped to React components, a default component if the map does not contain a component for a Slice, and an optional user-provided context value.
-
MissingSlice
provides helpful information during development and is used as the default Slice component.
-
SliceZone
is overwhelmingly simple. Its flexibility comes in the way of using JavaScript's language features.
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.
- Use higher-level types from
@prismicio/types
to simplify generated types.
- Organize component exports systematically (for example, all Slices could be imported and exported through one file).
- Include as little code as possible in generated code.
- Perform as little magic as possible in generated code.
- 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!