reactjsreact-hooks

React useEffect dependency array with ref.current.property vs ref.current


I have a React component that uses useImperativeHandle to expose a Google Maps Street View panorama instance. My intention is for the StreetView ref to be attached to the Google Maps ref after it is available, because after mounting, the first call to useEffect (with an empty dependency array) has streetViewRef.current.panorama as undefined.

I was able to get to this to work by adding streetViewRef.current?.panorama to the useEffect dependencies (in that the effect reruns after the value is set), but I don't have a great understanding of why this works (and was under the impression that refs weren't to be used with effects).

// StreetView.tsx
const StreetView = forwardRef((props, ref) => {
  useImperativeHandle(ref, () => ({
    updateView,
    panorama: streetViewPanoramaRef.current,
  }));
  // ... rest of component
});
// Parent.tsx
const ParentComponent = () => {
  const streetViewRef = useRef<StreetViewHandle>(null);
  const mapRef = useRef<GoogleMap>(null);

  useEffect(() => {
    if (streetViewRef.current && mapRef.current?.map) {
      streetViewRef.current.updateView(/* ... */);
      mapRef.current.map.setStreetView(streetViewRef.current.panorama);
    }
  }, [streetViewRef.current?.panorama]); // Why is this okay?

  return (
    <>
      <GoogleMap ref={mapRef} />
      <StreetView ref={streetViewRef} />
    </>
  );
};

I have the following questions:

  1. In my parent component, why is it wrong to include streetViewRef.current in the dependency array, but why does including streetViewRef.current?.panorama solve my problem?
  2. At the same time, why doesn't the linter complain about a missing dependency when I omit panorama from the dependency array?
  3. What is the correct way to solve this problem?

Thanks :)


Solution

    1. In my parent component, why is it wrong to include streetViewRef.current in the dependency array, but why does including streetViewRef.current?.panorama solve my problem?

    The ref.current is a stable object that isn't replaced after it's created. This means that after the first render, it won't trigger useEffect. The panorama property changes, and that's why it triggers the useEffect. However, changing a property on ref.current doesn't cause a re-render, so in you case something else is causing a re-render. This means that if you remove the cause for re-render, the useEffect won't be called.

    1. At the same time, why doesn't the linter complain about a missing dependency when I omit panorama from the dependency array?

    The linter knows that since the ref isn't replaced, the ref in the closure is never stale (points to a previous instance), so it ignores it. The linter also doesn't require a set state function, because it knows it's stable.

    1. What is the correct way to solve this problem?

    When you the ref change to trigger a re-render, you can use useState and pass the set state function as a ref callback function. When both components are mounted, they set their refs, and trigger the useEffect. Only when both are ready the useEffect's condition is true.

    Sample code (not tested):

    // StreetView.tsx
    const StreetView = forwardRef((props, ref) => {
      // function to update parent when ready
      const streetViewPanorama = useCallback(panorama => {
        ref({
          updateView,
          panorama
        })
      }, [ref]);
      
      return (
        <div ref={streetViewPanorama}>
          content
        </div>
      )
    });
    
    
    // Parent.tsx
    const ParentComponent = () => {
      const [streetViewRef, setStreetViewRef] = useState<StreetViewHandle>(null);
      const [mapRef, setmapRef] = useState<GoogleMap>(null);
    
      useEffect(() => {
        if (streetViewRef && mapRef) {
          streetViewRef.updateView(/* ... */);
          mapRef.map.setStreetView(streetViewRef.panorama);
        }
      }, [streetViewRef, mapRef]);
    
      return (
        <>
          <GoogleMap ref={mapRef} />
          <StreetView ref={streetViewRef} />
        </>
      );
    };