Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

On hooks #242

Open
JamesRamm opened this issue May 12, 2021 · 1 comment
Open

On hooks #242

JamesRamm opened this issue May 12, 2021 · 1 comment

Comments

@JamesRamm
Copy link

JamesRamm commented May 12, 2021

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.

@nicolaskruchten
Copy link
Member

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants