loading...

Embedding React components in Elm with custom elements

norpan profile image norpan ・1 min read

Elm is great, but sometimes there are things that exist outside of the Elm ecosystem that you want to use. In this case we wanted a dynamic form builder based on JSON Schema. While it's certainly possible to write such a thing in Elm, we just wanted something that worked to start with.

There are a number of dynamic form builders out there, but react-jsonschema-form has a simple API and works well for us right now.

I've not included any screenshot, because it really works just like the React version, so see screenshots there.

Basic principles

We have experimented with different ways to embed custom elements in Elm, but have found that the following principles work well and make the code easy to read and write:

  • Make one javascript file and one corresponding Elm module.
  • Only use properties, not attributes, to communicate with the component from Elm.
  • Keep internal copies of all properties in the component.
  • Do not keep any state in the component, if possible, keep all state in Elm.
  • Render component on property change, provided that all mandatory properties are set.
  • Only use custom events to communicate with Elm from the component, and bind these to the relevant React events.

In practice

Now, how does this work in practice? See the code listings below. Here are some implementation notes:

  • As you can see, we set internal properties in the custom element, for instance this._schema.
  • As you also can see, we use this. even when you don't need to. I find this helps knowing what thing I'm actually using.
  • Each time a property is set, we call renderForm() which checks to see if the relevant properties are set, and then in turn calls ReactDOM.render() to create the React component.
  • We bind the custom element internal properties to the React properties.
  • We bind our custom event dispatchers to the React event handlers. Note that you need to use the detail: field when creating a custom event, any other field will just be dropped.
  • In Elm, we use the generated <react-jsonschema-element> just as we would use any other HTML element.
  • You can see that we pass everything as Json.Decode.Value. In this case this is what we want, because we keep this in Elm as Value due to its dynamic nature, but you can of course put any JSON decoder in the Html.Events.on handler, as well as any JSON encoder in the Html.Attributes.property call.
  • In your Main, we let Elm be the keeper of the state, so any time we get an onChange we change the data in our model, passing it to the component again. This lets us keep in sync with the React component's internal state.

Note

Code

The javascript file:

import React from "react";
import ReactDOM from "react-dom";
import Form from "react-jsonschema-form";

// React jsonschema form custom element
class ReactJsonschemaForm extends HTMLElement {
  set schema(value) {
    this._schema = value;
    this.renderForm();
  }

  set uiSchema(value) {
    this._uiSchema = value;
    this.renderForm();
  }

  set data(value) {
    this._data = value;
    this.renderForm();
  }

  renderForm() {
    // Only render if schema property has been set
    if (this._schema) {
      ReactDOM.render(
        React.createElement(Form,
          {
            schema: this._schema,
            formData: this._data ? this._data : undefined,
            uiSchema: this._uiSchema ? this._uiSchema : undefined,
            onChange: this.sendChange.bind(this),
            onSubmit: this.sendSubmit.bind(this)
          },
          React.createElement('div', null, [
            React.createElement('button', { key: "submit", type: "submit" }, "Submit"),
            React.createElement('button', { key: "cancel", type: "button", onClick: this.sendCancel.bind(this) }, "Cancel")
          ])
        ),
        this
      );
    }
  }

  sendChange(change) {
    this.dispatchEvent(new CustomEvent('form-change', { detail: change.formData }));
  }

  sendSubmit(change) {
    this.dispatchEvent(new CustomEvent('form-submit', { detail: change.formData }));
  }

  sendCancel() {
    this.dispatchEvent(new CustomEvent('form-cancel'));
  }
}
customElements.define('react-jsonschema-form', ReactJsonschemaForm);

The Elm module:

module ReactJsonschemaForm exposing (view)

import Html
import Html.Attributes
import Html.Events
import Json.Decode

view :
    { schema : Json.Decode.Value
    , uiSchema : Json.Decode.Value
    , data : Json.Decode.Value
    , onChange : Json.Decode.Value -> msg
    , onSubmit : Json.Decode.Value -> msg
    , onCancel : msg
    }
    -> Html.Html msg
view { onChange, onSubmit, onCancel, schema, uiSchema, data } =
    Html.node "react-jsonschema-form"
        [ Html.Attributes.property "uiSchema" uiSchema
        , Html.Attributes.property "data" data
        , Html.Attributes.property "schema" schema
        , Html.Events.on "form-change" (Json.Decode.field "detail" Json.Decode.value |> Json.Decode.map onChange)
        , Html.Events.on "form-submit" (Json.Decode.field "detail" Json.Decode.value |> Json.Decode.map onSubmit)
        , Html.Events.on "form-cancel" (Json.Decode.succeed onCancel)
        ]
        []

Interesting parts of the Main file:

module Main exposing (main)

import Json.Decode
import ReactJsonschemaForm

type Model =
    { data: Json.Decode.Value
    , schema: Json.Decode.Value
    , uiSchema: Json.Decode.Value
    , ...
    }

type Msg
    = FormChanged Json.Decode.Value
    | FormSubmitted Json.Decode.Value
    | FormCancelled

update msg model =
    case msg of
        FormChanged data ->
            ( { model | data = data }, Cmd.none )
        FormSubmitted data ->
            ( model, sendDataCmd data )
        ...

view model =
    ReactJsonschemaForm.view
        { schema = model.schema
        , uiSchema = model.uiSchema
        , data = model.data
        , onChange = FormChanged
        , onSubmit = FormSubmitted
        , onCancel = FormCancelled
        }

Discussion

pic
Editor guide
Collapse
andreapavoni profile image
Andrea Pavoni

I know React and some very basic bits of Elm, but I didn't understand how it works. I mean, you gave the same name to both the components, and I'm finding it hard to get how both are interacting.

Can you explain/disambiguate?

Nice topic anyway, I had a similar task some year ago: dinamically render a form from an XML doc (along with XML Schema). The very first PoC was to use JSX to render XML tags like React components.

Collapse
norpan profile image
norpan Author

Hi!

Yes, actually there are three components involved. The first is the React component, the second is the web component, and the third is the Elm module. They exist in different layers, the web component wraps the React component and the Elm module wraps the web component.

Hopefully this clears it up. Feel free to ask for additional clarification.

Collapse
andreapavoni profile image
Andrea Pavoni

Yes, this clears it up a lot. Thank you :-)