skip to Main Content

We have a code stack with lots of legacy jQuery components. We’re moving toward React, and one of the steps is wrapping jQuery with React.

However, managing the state of a non-React child component like this doesn’t seem to be something that is commonly covered for useEffects. es-lint/exhaustive-deps doesn’t like any of my solutions. I’ve reviewed https://overreacted.io/a-complete-guide-to-useeffect/ and React docs, but I’m still not sure what the right answer is.

The naive function component looks like this:

const MyReactFunctionComponent = (props) => {
  const element = useRef(null);
  const [JQueryComp, setJQueryComp] = useState(null);

  const renderJQueryHelper = () => {
    // Not 1-1 props match, lot of transformation and helper functions
    const JQueryProps = { ...props };
    return new myJQueryComponent(JQueryProps, element.current);
  };

  useEffect(() => {
    // only heavy render on first mount
    setJQueryComp(renderJQueryHelper());
    return () => {
      JQueryComp.destroy();
    };
  }, []); // warn: missing deps 'JQueryComp' and 'renderJQueryHelper'
  
  // call update on every reRender, comp diffs the props itself.
  if (JQueryComp) {
    JQueryComp.update(props);
  }

  return <div ref={element} />;
};

In theory, I could move the entire helper inside the useEffect, but this turns into a mess very quickly and I’d like to avoid that. Following various guides, I arrived at this solution, with a useRef to store the useCallback.

  const renderJQueryHelper = useCallback(() => { ..., [props]);

  const helperRef = useRef(renderJQueryHelper);

  useEffect(() => {
    setJQueryComp(helperRef.current());
    ...

This works for helper functions, and I’ve already used this elsewhere. But it doesn’t cover JQueryComp, which I need to be able to call destroy. It also doesn’t handle cases where I do want to run the heavy render helper more often, like if the jQuery component crashes, or if anything else is more complicated. I feel like I must be missing something.

I’ll include example implementation of JQueryComp, as well as how this looks in a class component where it seems much simpler.

const myJQueryComponent = (props, element) => {
  const $element = $(element);
  $element.addClass('my-JQuery-component');

  const initialize = () => {
    // lots of JQuery code here, attaching containers, event listeners, etc.
    // eventually renders other JQuery components
  };

  const render = () => {
    if ($element.children().length > 0) {
      $element.trigger('JQuery_COMP_UPDATE', props);
    } else {
      initialize();
    }
  };

  this.update = _.debounce((newProps) => {
    if (newProps.type !== props.type) {
      this.destroy();
    }

    if (!_.isEqual(newProps, props)) {
      props = newProps;
      render();
    }
  }, 100);

  this.destroy = () => {
    $element.trigger('JQuery_COMP_DESTROY').empty();
  };

  render();
};

class MyReactClassComponent extends React.Component {
  renderJQueryHelper() {
    // Not 1-1 props match, lot of transformation and helper functions
    const JQueryProps = {...props}
    return new myJQueryComponent(JQueryProps, this.element);
  }

  componentDidMount() {
    this.JQueryComp = renderJQueryHelper();
  }

  componentDidUpdate() {
    if (!this.JQueryComp) {
      // JQuery comp crashed?
      this.JQueryComp = renderJQueryHelper
    }

    this.JQueryComp.update(this.props);
  }

  componentWillUnmount() {
    if (this.JQueryComp) {
      this.JQueryComp.destroy();
    }
  }

  render() {
    return <div ref={(element) => (this.element = element)} />;
  }
}

2

Answers


  1. Chosen as BEST ANSWER

    This is what I ended up with.

    const elementRef = useRef(null);
    const jQueryCompRef = useRef(null);
    
    // This ends up being redeclared every render, but that's fine.
    const jQueryProps = someComplexHelperFunction(props);
    
    useEffect(() => {
      if (!jQueryCompRef.current && elementRef.current) {
        jQueryCompRef.current = new myJQueryComponent(jQueryProps, elementRef.current);
      }
      // Turns out, I do want this to run every frame. If the component crashes,
      // this will attempt to recreate it.
    }, [jQueryProps]);
    
    useEffect(() => {
      return () => {
        jQueryCompRef.current?.destroy();
      };
      // But, I only want to destroy it on unmount. Otherwise you get screen flashing.
    }, []);
    

  2. I think both your initial solution and your "arrived-at" solution are very close to being correct. I don’t think the local state is necessary though, so I believe the myJQueryComponent component/object reference can be stored in another React ref.

    1. Create a second React ref to hold a reference to a myJQueryComponent object.
    2. Use a mounting (empty dependency array so effect runs exactly once) useEffect hook callback (analogous to a React class component’s componentDidMount lifecycle method) to instantiate the myJQueryComponent and return a cleanup function (analogous to a class component’s componentWillUnmount lifecycle method) to destroy the current myJQueryComponent object when the component unmounts.
    3. Use a second useEffect hook to handle the component lifecycle where the props value changes over time and is used as a dependency to trigger updating the myJQueryComponent object (analogous to a class component’s componentDidUpdate lifecycle method).
    const MyReactFunctionComponent = (props) => {
      const elementRef = useRef(null);
      const jQueryCompRef = useRef();
    
      useEffect(() => {
        const jQueryProps = { ...props };
    
        jQueryCompRef.current = new myJQueryComponent(
          JQueryProps,
          elementRef.current
        );
    
        return () => {
          jQueryCompRef.current.destroy();
        };
        // NOTE: mounting effect only
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, []);
    
      useEffect(() => {
        jQueryCompRef.current.update(props);
      }, [props]);
      
      return <div ref={element} />;
    };
    

    On the off-hand chance that the above doesn’t quite work and React still needs a bit of a kick to know it should rerender the DOM is fully updated and repainted you can force a component rerender if necessary.

    Example:

    const useForceRerender = () => {
      const [, setState] = useState(false);
    
      // useCallback is used to memoize a stable callback
      return useCallback(() => setState(c => !c), []);
    };
    
    const MyReactFunctionComponent = (props) => {
      const elementRef = useRef(null);
      const jQueryCompRef = useRef();
    
      const forceRerender = useForceRerender();
    
      useEffect(() => {
        const jQueryProps = { ...props };
    
        jQueryCompRef.current = new myJQueryComponent(
          JQueryProps,
          elementRef.current
        );
    
        return () => {
          jQueryCompRef.current.destroy();
        };
        // NOTE: mounting effect only
        // eslint-disable-next-line react-hooks/exhaustive-deps
      }, []);
    
      useEffect(() => {
        jQueryCompRef.current.update(props);
        forceRerender();
      }, [forceRerender, props]);
      
      return <div ref={element} />;
    };
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search