Solid: How to get reference to rendered element after mounting?

Created on 7 Jun 2019  路  12Comments  路  Source: ryansolid/solid

I was searching the docs for the best practices for getting a ref to an element rendered by Solid. Use case: Passing an element reference to an external library.

I could probably just give it an id, create an effect, and use getElementById, but that looks like an anti-pattern in Solid.

The afterRender directive sounds like it does the job, but from the docs I cannot infer how it works, and it only exists for when and each. Is there something similar for an unconditional <div>?

Regarding getting the element reference I could simple use:

// example use of a plotting library
function Example() {
  let el = <div></div>
  // pass el to external library
  return el;
}

but than I'm missing lifecycle handling, i.e., the external library may require to receive el only after it gets mounted. The use case is plotting libraries that can only plot into the given element after the element has become visible, because they perform internal size measurements.

question

All 12 comments

Yeah that's a fair question. There is a ref binding to save you from having to write it like that. But it still is just a reference and has no concept of when it's attached to the DOM.

function Example() {
  let el;
  return <div ref={el}></div>
}

I currently don't have much of anything here. It's the nature of the JSX getting created down the tree and then attached back up the tree. It doesn't actually get attached until the last parent gets attached at the root. The context is so far removed by the time it's attached. Even afterRender only really tackles when items are inserted into their parents and are not necessarily connected to the DOM.

I usually handle this by scheduling a microtask or setTimeout 0. Ie..

Promise.resolve().then(() => /* do some measurement stuff */ )

Mind you this has been asked a few times in chat on Spectrum and once here for equivalent of useEffect from React. I probably need to take a stance. I just have been hoping that perhaps that a better suggestion came along or there naturally an opportunity presented that could address it in a nicer way.

It occurs to me it could be worth considering abstracting this into an onMounted function. Depending if it makes sense it could set a LIFO stack on resolution so that the children would fire before the parents.

So it would look like:

function Example() {
  let el;
  onMounted(() => /* do some measurement stuff */)
  return <div ref={el}></div>
}

Yes, for integration with external libraries keeping track of the mount status seems to be crucial. The onMounted effect looks perfect. Perhaps it needs a sibling onUnmounted to fully keep track of mount status.

To see how this would work out in practice on the example of wrapping a plotting library:

  • onMounted and onUnmounted can be used to determine the mount status.
  • when the props.plotData changes the component could check the mount status: If it is mounted calling plotLibrary(el, props.plotData) is safe; if it is not props.plotData can be cached and as soon as it gets mounted, we could call the plot library.
function PlotWrapper(props) {
  let el;

  const [state, setState] = createState({
    mounted: false,
    cachedPlotData: null,
  })

  createEffect(() => {
    // effect to monitor changes to props.plotData
    let newPlotData = props.plotData;
    if (sample(() => state.mounted)) {
      // already mounted => we can call into the external lib directly
      plottingLibrary.plot(el, newPlotData);
    } else {
      // not mounted => need to cache
      setState({cachedPlotData: newPlotData});
    }
  })

  onMounted(() => {
    if (state.cachedPlotData != null) {
      plottingLibrary.plot(el, state.cachedPlotData);
      setState({cachedPlotData: null});
    }
    setState({mounted: true})
 }

  onUnmounted(() => {
    setState({mounted: false})
  }

  return <div ref={el}></div>
}

Does that make sense?

Depending if it makes sense it could set a LIFO stack on resolution so that the children would fire before the parents.

Is it crucial that children fire before parents? The other way around would also make sense to me.

Typically I've observed children first for Vue and React. It's a little bit strange to do so here since I'm literally just making a stack. If you call it twice in the same component it would also be reversed order.

And that's the thing to understand here. Components in Solid are an illusion. They are just functions, with nothing special about them. I'm currently researching optimization techniques for inlining them. They literally do nothing. What matters is Reactive context the current code is executed in so I think onCleanup handles the unMounted case already. The Component won't become unMounted so much as removed when the portion of Reactive graph tears down. It's more generic than the mounting scenario. And I honestly am not tracking whether any given element is connected to the dom. el.isConnected and mutationObservers track that.

So, onMounted perhaps misnamed here I guess. Is more of a this portion of the graph has completely finished evaluating. Maybe I should call it something else to be less misleading. However, onMounted is definitely more recognizable. I'm actually having a hard time coming up with good names for it.

What matters is Reactive context the current code is executed in so I think onCleanup handles the unMounted case already.

My understanding was that onCleanup fires when the "component" gets destroyed, which is different from unmounting. Imaging an app that has multiple tabs, and each tab is a component. When switching between tabs, the components should maintain their internal state, i.e., it is not desired to destroy + recreate them on every tab switch. This can be easily achieved by keeping references to the tab components in the tab handler (example here). Thus, when using a Plot component within a tab, the onCleanup cannot be used for detecting onmounting. Basically the tab never gets destroyed, it just gets mounted/unmounted from its parent. As far as I can see an explicit onUnmount is required to solve the problem in general.

You are right about onCleanup. I don't think I have a reasonable way of doing this. There is no concept of mounting or unmounting for the components. They are just Factories. They run once. They are essentially:

function makeMeSomeDOMElements(props) {
   // I'm only executed once
   let ref;
   Promise.resolve(() => {
     // am I connected? probably let's look at ref
     // nothing reactive here am also run only once
   })
   return <div ref={ref} />;
}

const div= makeMeSomeDOMElements({})
document.body.appendChild(div);

//later
document.body.removeChild(div);
// How does the Component ever knows this happens? It doesn't really even exist anymore (outside of the closure context for the ref).

See if we didn't support fragments then I suppose we could make that div special, but there is no generalizable way to account for the component after the fact. All we can do is setup a mutation observer on the parent to detect unmounting.

However, I'd probably attack this from the data. Ie.. the parent knows the selected tab, so it can use the selection state to pass in a prop to the child. Once the dependency is made the tab can have effects that update based on the prop changing.

Your example is interesting because it passes the Components. To pull off what I'm suggesting it actually has to pass in factories for the Component:

const contents = [
  {name: 'page1', create: (isActive) => <Tab1 active={( isActive )} />},
  {name: 'page2', create: (isActive) => <Tab2 active={( isActive )} />},
]

function Tabs(props) {
  const [state, setState] = createState({
    activeIndex: 0
  })
  const Tabs = props.contents.map((tab, index) => tab.create(() => state.activeIndex === index))
  return (
    <>
      <div class="tabs is-boxed is-small">
        <ul>
          { props.contents.map((content, index) =>
            <li class={(index === state.activeIndex ? "is-active" : undefined)}>
              <a onclick={(event) => setState({activeIndex: index})}>
                {content.name}
              </a>
            </li>
          )}
        </ul>
      </div>
      {( Tabs[state.activeIndex] )}
    </>
  )
}

I mean this is only one option. After render on when will work too since it keeps track of it's insertions This is because control flows inverse the passive nature of standard JSX resolution. They actually are responsible for doing the insertions themselves.

Yes I though about that as well. For a bigger app it looks a bit tedious though to pass the isActive from a top level component all the way down the hierarchy to all components that need to know the mount state. Also I wasn't fully sure if the isActive semantics actually capture "is mounted" semantics properly, because of mounting at the root level: At the initial render, the tab handler sets isActive of the first child, but the entire reactive tree isn't mounted yet at the root level, so it is not safe to assume that isActive implies "is mounted".

I was also more thinking in hypothetical terms. It sounded like introducing an onMounted effect is possible in theory. Are you saying that introducing onUnmount on the other hand is impossible in general?

They both are incredibly difficult (never want to say impossible). On mounted is atleast fakeable in the immediate sense with timeouts, but that doesn't help you. It is not real. And you are correct, too that it's 2 pronged if you want to be connected as well. You need to know that the root is connected in addition to whether it is active. So it's a bit of both methods. In the parent you'd also set the timer and trigger some state that can also be part of that isActive calculations. But then what if is also part of this detached root scenario too.

Honestly while possible with this library I haven't really explored scenarios where you cache DOM nodes. It does seem like a nice advantage over Virtual DOM approaches but at that point you are in native DOM territory since the elements are sort of removed from the rest of structure. childList MutationObserver will work it just seemed a bit much, but given the need to only pass the ref when connected to the DOM it might be the most reasonable.

As pretty much always Andrea Giammarchi has a simple solution. I haven't tried it but what about this:
https://github.com/WebReflection/disconnected

I imagine it could be worked in pretty seamlessly, either with the new afterEffects hook or with a custom directive (takes a function that first arg is node so already compatible signature). It's likely could even use standard event bindings. I will think if there is more that can be done here, but it definitely addresses a deficit in DOM APIs.

import disconnected from 'disconnected';
const observe = disconnected({Event, WeakSet});

function Comp() {
  return <div $observe={true} onconnected={() => setState(...)} />
}

In general the rendering timing was a big blocker for me figuring out how to make a Context API. I managed to do it through Reactive Context but it wasn't obvious. I introduced Control Flow in JSX because I needed to optimize reconcilliation and couldn't do that always depending on the parent to insert.

Thinking about this some more I could see just having onConnected, and onDisconnected events being useful in general since Solid doesn't really have any equivalent. The mechanism is simple enough that it could be something detected during compilation like Implicit Event delegation and only included if used. Perhaps it's a candidate to be included in the runtime.

They both are incredibly difficult (never want to say impossible)

The mechanism is simple enough ...

Just to avoid confusion on the terminology: Is there a difference between "connected" and "mounted"?

In the context of talking about Components I believe so. I'm coming from the React mentality. Mounted being the state in which a component has had it's children rendered and attached to the DOM and unmounted being that the Component is no longer being executed as it is no longer part of the Render Tree (which is why I drew similarities to onCleanup).

Solid's output can exist outside of the render tree unlike React. Knowing that a Component is "mounted" is difficult. A Component may return a HTMLElement, a DocumentFragment that basically disappears once attached, or a function to be Wrapped in a Computation which defers actual rendering and may return either of the previous mentioned. There is no single point that can store the DOM state of the Component.

Whereas I'm using connected to indicate whether a given DOM element is connected to the DOM, but that is not necessarily related to a Component's potential "mounted" state. It is fairly straightforward to use Mutation Observers to track element connectivity, and to set state based on it, but it's not something that I can easily just automate into a "mounted" event on the Component/Reactive Context itself.

Here is an example using a mutation observer https://codesandbox.io/s/dom-lifecycle-u0gbd. Whether I can find a way to incorporate it in the core library in the future is under consideration. But this mechanism should work pretty well for most cases and applies the ability to watch globally. It could be wrapped in a hook to have a more narrow scope, we'd just need to make sure not to make too many of these watching the same nodes in the hierarchy.

I think the afterEffects method handles the initial question. The reason this issue is much more complicated is because both the desire to suspend DOM elements outside of the flow and then re-attach them and have that with a third party library that cares about DOM connectivity. I'm going to classify that under userland since once you take DOM nodes out of the flow you are dealing with DOM nodes and there are lots of solutions for that.

Was this page helpful?
0 / 5 - 0 ratings