Rendering a custom component imperatively in code

Hello,

I am running into a bit of an issue when trying to make a “composable” filter section in Builder.

What I have is a React component that you can pass a list of sections with their respective items, specifying the “type” of the section and the “type” of each individual item so that filtering can happen.

Now, the issue arises when trying to make a builder wrapper around this component, since the underlying component accepts any content as the section header and each individual item, because it has a very simple purpose, filter by type, the style is irrelevant.

The approach I took was composition, I created multiple components that semantically represent each part of the component tree, which in turn would map into sections with items, they don’t do anything, they are just there for grouping.

So my component tree looks like this:


Here is the code that implements all of this functionality:

/* eslint-disable @typescript-eslint/no-unsafe-assignment */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-unsafe-member-access */

import {
  Children,
  isValidElement,
  useEffect,
  useState,
  type FC,
  type PropsWithChildren,
  type ReactElement,
  type ReactNode,
} from 'react'
import { BuilderComponent, type BuilderElement } from '@builder.io/react'
import {
  type BuilderBlock,
  type RegisteredComponent,
} from '@builder.io/sdk-react'

import {
  FilterSection as FilterSectionReact,
  type Item,
  type SectionWithItems,
} from '@local/ui/FilterSection'

type FilterGroup<TFilterOptions extends string> = {
  title: string
  filterOptions: { name: TFilterOptions }[]
}

function transformFilterGroup<TFilterOptions extends string>(
  filterGroup: FilterGroup<TFilterOptions>,
) {
  return {
    ...filterGroup,
    filterOptions: filterGroup.filterOptions.map(
      filterOption => filterOption.name,
    ),
  }
}

function isBuilderBlock(block: any): block is BuilderBlock {
  return (
    typeof block === 'object' &&
    block !== null &&
    block !== undefined &&
    block['@type'] === '@builder.io/sdk:Element'
  )
}

function builderBlockToReactElement(
  block: BuilderBlock | BuilderBlock[],
  options: { isChild: boolean },
) {
  console.log(block)
  return (
    <BuilderComponent
      content={{
        data: {
          blocks: Array.isArray(block)
            ? (block as BuilderElement[])
            : ([block] as BuilderElement[]),
        },
      }}
      isChild={options.isChild}
    />
  )
}

function getGroupHeader(groupChildren: BuilderBlock[]): ReactElement | null {
  const groupHeaderBlock = groupChildren.find(
    block => block.component?.name === 'FilterSectionGroupHeader',
  )

  const effectiveHeader = groupHeaderBlock?.children

  const groupHeaderElement =
    groupHeaderBlock === undefined
      ? null
      : builderBlockToReactElement(effectiveHeader, { isChild: true })

  return groupHeaderElement
}

function getItemsForGroup<TItemTypes extends string>(
  groupChildren: BuilderBlock[],
) {
  const groupItems: Item<TItemTypes>[] = []

  for (const groupItemBlock of groupChildren) {
    const itemType = groupItemBlock.component?.options?.type as
      | TItemTypes
      | undefined

    if (itemType === undefined) continue

    const effectiveItem = groupItemBlock.children

    groupItems.push({
      type: itemType,
      itemElement: builderBlockToReactElement(effectiveItem, {
        isChild: true,
      }),
    })
  }

  return groupItems
}

function buildSectionsWithItems<
  TSectionTypes extends string,
  TItemTypes extends string,
>(filterSectionGroups: ReactNode[]) {
  const groupWithItems: SectionWithItems<TSectionTypes, TItemTypes>[] = []

  for (const group of filterSectionGroups) {
    if (!isValidElement(group)) continue

    const groupBuilderBlock = group.props.block
    if (!isBuilderBlock(groupBuilderBlock)) continue

    const groupType = groupBuilderBlock.component?.options?.type as
      | TSectionTypes
      | undefined

    if (groupType === undefined) continue

    const groupHeader = getGroupHeader(groupBuilderBlock.children ?? [])
    const groupItems = getItemsForGroup<TItemTypes>(
      groupBuilderBlock.children ?? [],
    )

    groupWithItems.push({
      type: groupType,
      sectionHeader: groupHeader,
      items: groupItems,
    })
  }

  return groupWithItems
}

type FilterSectionProps<
  TSectionTypes extends string,
  TItemTypes extends string,
> = PropsWithChildren<{
  itemFilterGroup: FilterGroup<TItemTypes>
  sectionFilterGroup: FilterGroup<TSectionTypes>
}>

export const FilterSection = <
  TSectionTypes extends string,
  TItemTypes extends string,
>({
  itemFilterGroup,
  sectionFilterGroup,
  children,
}: FilterSectionProps<TSectionTypes, TItemTypes>) => {
  const itemFilterGroupTransformed = transformFilterGroup(itemFilterGroup)
  const sectionFilterGroupTransformed = transformFilterGroup(sectionFilterGroup)
  const filterSectionGroups = Children.toArray(children)

  const sectionsWithItems = buildSectionsWithItems<TSectionTypes, TItemTypes>(
    filterSectionGroups,
  )

  // Somehow <BuilderComponent /> is causing a hydration error. I was not able to pinpoint the issue so we just have to skip pre-rendering on the server.
  const [isClient, setIsClient] = useState(false)

  useEffect(() => {
    setIsClient(true)
  }, [])

  if (!isClient) return null

  return (
    <FilterSectionReact
      itemFilterGroup={itemFilterGroupTransformed}
      sectionFilterGroup={sectionFilterGroupTransformed}
      sectionsWithItems={sectionsWithItems}
    />
  )
}

const filterGroupInput = {
  type: 'object',
  required: true,
  subFields: [
    {
      name: 'title',
      type: 'string',
      defaultValue: 'Title for filter group',
      required: true,
    },
    {
      name: 'filterOptions',
      type: 'list',
      required: true,
      subFields: [
        {
          name: 'name',
          type: 'string',
          required: true,
        },
      ],
      defaultValue: [],
    },
  ],
  defaultValue: {
    title: 'Some filter group',
    filterOptions: [],
  },
}

export const FilterSectionRegistration: RegisteredComponent = {
  component: FilterSection,
  name: 'Filter Section',
  inputs: [
    {
      name: 'sectionFilterGroup',
      ...filterGroupInput,
    },
    {
      name: 'itemFilterGroup',
      ...filterGroupInput,
    },
  ],
  canHaveChildren: true,
  childRequirements: {
    message:
      'Children component must be a Filter Section Group. Use it to group Filter Section Items together to compose a filter section',
    component: 'FilterSectionGroup',
  },
}

const NoOp: FC<PropsWithChildren<{ type: string }>> = ({ children }) => children

export const FilterSectionGroupRegistration: RegisteredComponent = {
  component: NoOp,
  name: 'FilterSectionGroup',
  inputs: [
    {
      name: 'type',
      friendlyName: 'Group type',
      type: 'string',
      required: true,
      defaultValue: 'Some type',
      helperText: `Type of the section used for filtering. It MUST match one of the filter options found in the FilterSection component under the "Section filter group" input. Case sensitive`,
    },
  ],
  canHaveChildren: true,
  childRequirements: {
    message:
      'Children component must be a Filter Section Item or Filter Section Group Header.',
    query: {
      // eslint-disable-next-line @typescript-eslint/naming-convention
      'component.name': {
        $in: ['FilterSectionGroupItem', 'FilterSectionGroupHeader'],
      },
    },
  },
}

export const FilterSectionGroupHeaderRegistration: RegisteredComponent = {
  component: NoOp,
  name: 'FilterSectionGroupHeader',
  canHaveChildren: true,
}

export const FilterSectionGroupItemRegistration: RegisteredComponent = {
  component: NoOp,
  name: 'FilterSectionGroupItem',
  inputs: [
    {
      name: 'type',
      friendlyName: 'Item type',
      type: 'string',
      required: true,
      defaultValue: 'Some type',
      helperText: `Type of the item used for filtering. It MUST match one of the filter options found in the FilterSection component under the "Item filter group" input. Case sensitive`,
    },
  ],
  canHaveChildren: true,
}

What I’m trying to achieve is to extract the child of each “semantic” component and render it.

My specific issue is with the function “builderBlockToReactElement”, it seems like when passing the content prop to the element, if that content is NOT a DEFAULT builder block, it won’t render component, which I don’t understand why, since the representation of both custom and default components as blocks have the same object shape, so why can’t builder recreate the custom component, but it’s able to recreate it’s own as React components?



Yes, I tried ONLY putting the custom component as a child, and the issue persists, as long as it’s a custom component.

So if there’s a way to imperatively/programatically render a custom builder component or if what I’m trying to accomplish is impossible, please let me know, because I’ve searched through swathes of the internet to find nothing.

Builder content link

Builder public api key
5861e770ff6944ae968b0e0096a65be9

Code stack you are integrating Builder with
NextJS, React

Hey @viktorshev welcome to Builder forum. Could you please confirm the SDK version you are using?

@sheema I am using these versions:

    "@builder.io/dev-tools": "1.0.5",
    "@builder.io/sdk": "2.2.2",
    "@builder.io/sdk-react": "^1.0.29",

Hey @viktorshev I would recommend removing "@builder.io/sdk-react": "^1.0.29", . You should only need "@builder.io/react" Could you please upgrade your sdks as well? You should be able to do that as follows-

npm install @builder.io/react@latest
npm install @builder.io/sdk@latest

I will have to try this later, since I’m a bit short on time, but regardless, I really doubt this is a version issue, these versions I listed are nowhere near old, the latest versions for all the packages I listed are like only 2 SemVer patches higher, if rendering custom components was a feature before, I am sure it would work with the current versions I got, if you got it to work on the latest versions of the SDKs let me know.