How to make Hotwire play nicely with React, Elm, etc

Hello,

I’ve been playing and learning about the new rails 7 default stack this past week, fascinating :slight_smile:

I’d like to use turbo drive + turbo streams for most interactions, bring in Stimulus when I need a bit more dynamic behavior as it should be.

But for complex UI pieces, the ones where I wouldn’t want to introduce state bugs, I’d like to still be able to introduce more complex tools such as React or Elm into the mix. In theory, if they’re isolated from the rest of the UI, this should work pretty well.

I’m finding this quite tricky to setup though. I tought about using Stimulus controllers to initialize the UI components but this causes 2 problems:

  1. they need to be teared down not to leak memory
  2. it seems that turbo drive caches the pages before Stimulus’ disconnect hook kicks in.

I’ve got a demo here where I introduced Elm + React to a Rails 7 code base, via esbuild:

While “it works”, this is a subpar implementation. And I have a few problems:

  • it seems that Elm has no teardown hook so I could probably leak memory.
    • This seems like a show stopper.
  • upon page revisit, I can see the old Elm component’s state, briefly (due to turbo restore by the looks of it)
  • it looks like displaying from the cache re-triggers javascript actions before Stimulu’s connect hook kicks in.
    • Then the same javascript actions trigger again after Stimulus’ connect hook kicked in.
  • strangely enough I don’t see briefly the old React component’s state briefly upon re-entry (??)
  • while the React component looks ok in prod (Heroku, see the js console), I see some erratic behavior in my dev env
    • the component fails to load silently and randomly
    • the stimulus controller seems to die silently and thus does’n load the React component (no more js log messages can be observed until a full page reload)

So basically I’m wondering if I’m doing something dumb, if there’s a bette way to handle this :slight_smile:

Initially, I wanted to see if I could use Elm nicely/easily with this setup. Realizing I would probably face memory leaks, I then implemented a test with React (since I can use its unmount hook)

But the same question would apply to other technologies such us Vue, Svelte, etc.

Any ideas? Has anyone played with this?

I don’t know about Elm, but a few points about your React wrapper:

  • initialize() only runs the first time one of the controllers gets instantiated on an element on the page. This means it won’t re-run after a cache restore – only on the first page load. That code should be in connect(), and that might explain the need for a full page refresh to get things working.
  • I’d recommend mirroring the state of the React component in a Stimulus Values attribute, so for example you’d have a this.componentStateValue and on each update to the State, you’d override this with a full copy of the object. On connect(), you’d re-instantiate the React component with the value of that object. If you’re expecting this data attribute (because it ends up being a data attribute, in Stimulus the state is meant to be saved in the DOM for those cache restores to work as expected) to change from something else, than you can implement a componentStateValueChanged(newValue) method. All this is handy because you won’t need to explicitly store the state of the component on disconnect(), but for performance you might to only save the component’s state to componentStateValue on disconnect() anyway.

Thanks @pascallaliberte!

Initially I did use Stimulus’ connect hook but I somehow came to the conclusion that it was a no-go due to the multiple bugs I saw in my dev environment. Taking a step back, I must have had a bug in my tooling or just got tired :stuck_out_tongue:

If I got this, to keep a coherent state client side, persisting state an the server side before page re-entry becomes a must. Otherwise weird stuff happens :slight_smile:

I’m not sure yet if I should opt-out of caching the html fragment but it’s a good start.

I’ll probably remove the linked repo at the top of the post in a while, so here is the basic implementation for reference:

// app/javascript/controllers/react_init_controller.jsx

import {Controller} from "@hotwired/stimulus"

import * as React from 'react';
import * as ReactDOM from "react-dom/client";

import Clicker from "../components/Clicker";

/*
    Usage:

        <div
            data-turbo-cache="true"
            data-controller="react-init"
            data-react-init-count-value="123"
        ></div>
*/
export default class extends Controller {
    static values = {
        count: Number
    }

    connect() {
        console.log(new Date(), "Connecting with value: ", this.countValue);
        this.root = ReactDOM.createRoot(this.element);
        this.root.render(<Clicker count={this.countValue} onCountChanged={ (val) => this.countValue = val }/>);
    }

    disconnect() {
        this.root.unmount();
    }

    countValueChanged(val, prevVal) {
        // countValue has already been changed by Stimulus here
        console.log("new value:", this['countValue'], "val:", val, "prevVal:", prevVal);
    }
}
// app/javascript/components/Clicker.jsx

import * as React from "react";
import {useState} from "react";

export default function Clicker(props) {
    const [count, setCount] = useState(props.count);

    function inc() {
        const newCount = count + 1;
        setCount(newCount);
        props.onCountChanged(newCount);
    }

    function dec() {
        const newCount = count - 1;
        setCount(newCount);
        props.onCountChanged(newCount);
    }

    function resetState() {
        setCount(props.count);
        props.onCountChanged(props.count);
    }

    return (
        <>
            <button onClick={dec}>-</button>
            <button onClick={inc}>+</button>
            <br/>
            <button onClick={resetState}>Reset state!</button>

            <h3>COUNT={count}</h3>
        </>
    )
}
1 Like