react-plotly.js icon indicating copy to clipboard operation
react-plotly.js copied to clipboard

On hooks

Open JamesRamm opened this issue 3 years ago • 1 comments

This is not an issue, rather a comment with examples. Hooks have been around in React for a few releases now and they can make integration with 3rd party DOM libraries much easier.

As an example, integrating Plotly can be as simple as this:

// global: Plotly
import { useState, useLayoutEffect } from 'react';
function usePlotlyBasic({ data, layout, config }) {
   const [ref, setRef] = useState(null);
   const useLayoutEffect(() => {
      ref && Plotly.react(ref, { data, layout, config })
      return () => {
         ref && Plotly.purge(ref)
      };
   }, [ref, data, layout, config])

   return setRef;
}

It exposes a callback ref which is used like so:

function MyPlot(props) {
  const ref = usePlotlyBasic(props)
  return <div ref={ref} />
}

This tiny function gives a lot of the functionality of react-plotly. Like react-plotly, it is 'dumb' in that you must update the props (layout, config, state) for it to update (and that update must be immutable). Since all rendering of the parent <div> is handed off to the caller, there is no need to accept props such as divId, className, style. In doing so, the code is both simplified and less restrictive to the user.

Immutable updates can be a pain to manage, but we can also offload that to the hook. In the next example, I am making use of streams (from the excellent flyd library, but you could use rxjs, most, xstream, kefir etc...) so that the user can just pass partial updates:

// global: Plotly
import { useState, useLayoutEffect } from 'react';
import { stream, scan } from 'flyd'
import { mergeDeepRight } from 'ramda';

export default function usePlotly() {
   const updates = stream();
   const plotlyState = scan(mergeDeepRight, { data: [], config: {}, layout: {}}, updates);

   const [internalRef, setRef] = useState(null);
   useLayoutEffect(() => {
      if (internalRef) {
         const endS = plotlyState.map(state => {
            Plotly.react(internalRef, state);
         });
         return () => {
            Plotly.purge(internalRef);
            endS.end(true);
         };
      }
   }, [internalRef]);


   return { ref: setRef, updates };
}

Here, you can just push partial updates on to the updates stream:

const randomData = () => Array.from({ length: 10 }, Math.random)
function MyPlot(props) {
  const { ref , updates } = usePlotly(props)

  const onClick = () => updates({ data: { y:  randomData(), type: 'scatter' } });

  return <div>
               <button>Plot!</button>
              <div ref={ref} />
           </div>
}

I am using mergeDeepRight from ramdajs to merge the previous state with the partial update, but you could use your own function (e.g. based on immerJS for example, or using JSON Patch).

A more complex example. Plotly's/react-plotly's responsive behaviour has always bugged me. The responsive property only responds to window resize events. I often want to allow users to resize the individual element holding the chart. Using hooks, streams and the ResizeObserver API, this is trivial:

// global: Plotly, debounce
import { useLayoutEffect, useState, useCallback } from 'react';
import { head, prop, compose, pick, objOf, mergeDeepRight } from 'ramda';
import { stream, scan } from 'flyd';

const getSizeForLayout = compose(objOf('layout'), pick(['width', 'height']), prop('contentRect'), head);

export default function usePlotly() {
   const updates = stream();
   const plotlyState = scan(mergeDeepRight, { data: [], config: {}, layout: {} }, updates);

   const observer = new ResizeObserver(debounce(compose(updates, getSizeForLayout), 100));
   const [internalRef, setRef] = useState(null);
   useLayoutEffect(() => {
      if (internalRef) {
         observer.observe(internalRef);
         const endS = plotlyState.map(state => {
            Plotly.react(internalRef, state);
         });

         return () => {
            Plotly.purge(internalRef);
            observer.unobserve(internalRef);
            endS.end(true);
         };
      }
   }, [internalRef]);

   return { ref: setRef, updates };
}

Here, I used ramdajs to extract the new size information from the ResizeObserver callback, but it could be written in a more imperative style as:

function getSizeForLayout(entries) {
    const { width, height } = entries[0].contentRect;
    return { layout: { width, height } };
}

I also included a debounce function (easy to implement natively or take from e.g. lodash) to prevent to many redraws when you are actively dragging to resize.

OK, final example. With hooks, it becomes trivial to expose other parts of the plotly API. Here I include all of the previous examples and add a stream which will take data updates to pass directly to Plotly.extendTraces:

// global: Plotly, debounce
import { useLayoutEffect, useState } from 'react';
import { head, prop, compose, pick, objOf, mergeDeepRight } from 'ramda';
import { stream, scan } from 'flyd';

const getSizeForLayout = compose(objOf('layout'), pick(['width', 'height']), prop('contentRect'), head);

export default function usePlotly() {
   const updates = stream();
   const appendData = stream();
   const plotlyState = scan(mergeDeepRight, { data: [], config: {}, layout: {} }, updates);

   const observer = new ResizeObserver(debounce(compose(updates, getSizeForLayout), 100));
   const [internalRef, setRef] = useState(null);
   useLayoutEffect(() => {
      if (internalRef) {
         observer.observe(internalRef);
         const endS = plotlyState.map(state => {
            Plotly.react(internalRef, state);
         });

         const endAppend = appendData.map(({ data, tracePos }) => Plotly.extendTraces(internalRef, data, tracePos));

         return () => {
            Plotly.purge(internalRef);
            observer.unobserve(internalRef);
            endAppend.end(true);
            endS.end(true);
         };
      }
   }, [internalRef]);
   return { ref: setRef, updates, appendData };
}

Overall, my point is that I think hooks allow you to provide a smaller, more flexible and easy to use API. I would love to see a usePlotly hook (hopefully incorporating some of the ideas above as options) as an alternative to the <Plot> widget.

JamesRamm avatar May 12 '21 13:05 JamesRamm

This is a great rundown, thank you!

I would love to see a usePlotly hook

Sounds like an awesome idea :) Would you be interested in submitting a pull request implementing it?

nicolaskruchten avatar May 17 '21 12:05 nicolaskruchten