Search code examples
reactjstypescriptweb-componentstenciljs

Rendering React component from inside StencilJS component and mapping slot to props.children


I would like to wrap an existing React component inside a StencilJS component.

I have a kinda mvp thing working by calling ReactDom.render inside the StencilJS componentDidRender hook and after rendering moving the into the react child element, however I'm wondering if there is a nicer way to achieve this.

I don't like that this requires two wrapper elements inside the host, and manually moving the slot into the React component feels pretty icky.

Example code - the existing react component I am trying to render here is a Bootstrap Alert from the react-bootstrap project, just as an example.

import {
    Component,
    ComponentInterface,
    Host,
    h,
    Element,
    Prop,
    Event,
} from '@stencil/core';
import { Alert, AlertProps } from 'react-bootstrap';
import ReactDOM from 'react-dom';
import React from 'react';

@Component({
    tag: 'my-alert',
    styleUrl: 'my-alert.css',
    shadow: false,
})
export class MyAlert implements ComponentInterface, AlertProps {
    @Element() el: HTMLElement;

    @Prop() bsPrefix?: string;
    @Prop() variant?:
        | 'primary'
        | 'secondary'
        | 'success'
        | 'danger'
        | 'warning'
        | 'info'
        | 'dark'
        | 'light';
    @Prop() dismissible?: boolean;
    @Prop() show?: boolean;
    @Event() onClose?: () => void;
    @Prop() closeLabel?: string;
    @Prop() transition?: React.ElementType;

    componentDidRender() {
        const wrapperEl = this.el.getElementsByClassName('alert-wrapper')[0];
        const slotEl = this.el.getElementsByClassName('slot-wrapper')[0];

        const alertProps: AlertProps = {
            variant: this.variant,
            dismissible: this.dismissible,
            show: this.show,
            onClose: this.onClose,
            closeLabel: this.closeLabel,
            transition: this.transition,
        };

        ReactDOM.render(
            React.createElement(
                Alert,
                alertProps,
                React.createElement('div', { className: 'tmp-react-child-el-class-probs-should-be-a-guid-or-something' })
            ),
            wrapperEl
        );

        const reactChildEl = this.el.getElementsByClassName(
            'tmp-react-child-el-class-probs-should-be-a-guid-or-something'
        )[0];
        reactChildEl.appendChild(slotEl);
    }

    render() {
        return (
            <Host>
                <div class="alert-wrapper"></div>
                <div class="slot-wrapper">
                    <slot />
                </div>
            </Host>
        );
    }
}


Solution

  • Just a couple tips:

    • You don't need the wrapperEl, you can just render your react component into the host element this.el instead.
    • Since you're not using shadow, you can clone your component's slot content and use that as the react component's children.
    • Instead of this.el.getElementsByClassName('slot-wrapper')[0] you can also use this.el.querySelector('.slot-wrapper'), or use an element reference.
    • onClose shouldn't be used as a prop name because on... is also how event handlers are bound, e. g. if your component emits a 'click' event, then you can bind a handler for it by setting an onClick handler on the component. You've decorated it as an event though but that doesn't work afaik.

    Working example on codesandbox.io:
    https://codesandbox.io/s/stencil-react-mv5p4?file=/src/components/my-alert/my-alert.tsx
    (this will also work with respect to re-rendering)

    import {
      Component,
      ComponentInterface,
      Host,
      h,
      Element,
      Prop,
      Event,
    } from '@stencil/core';
    import { Alert, AlertProps } from 'react-bootstrap';
    import ReactDOM from 'react-dom';
    import React from 'react';
    
    @Component({
      tag: 'my-alert',
      shadow: false,
    })
    export class MyAlert implements ComponentInterface {
      @Element() host: HTMLMyAlertElement;
    
      @Prop() bsPrefix?: string;
      @Prop() variant?:
        | 'primary'
        | 'secondary'
        | 'success'
        | 'danger'
        | 'warning'
        | 'info'
        | 'dark'
        | 'light';
      @Prop() dismissible?: boolean;
      @Prop() show?: boolean;
      @Prop() closeHandler?: () => void;
      @Prop() closeLabel?: string;
      @Prop() transition?: React.ElementType;
    
      originalContent: any[];
    
      componentDidLoad() {
        // clone the original (slotted) content
        this.originalContent = Array.from(this.host.childNodes).map(node =>
          node.cloneNode(true),
        );
    
        this.componentDidUpdate();
      }
    
      componentDidUpdate() {
        const alertProps: AlertProps = {
          variant: this.variant,
          dismissible: this.dismissible,
          show: this.show,
          onClose: this.closeHandler,
          closeLabel: this.closeLabel,
          transition: this.transition,
          ref: el => el?.append(...this.originalContent), // content injected here
        };
    
        ReactDOM.render(React.createElement(Alert, alertProps), this.host);
      }
    
      render() {
        return (
          <Host>
            <slot />
          </Host>
        );
      }
    }