Michal Zalecki
Michal Zalecki
software development, testing, JavaScript,
TypeScript, Node.js, React, and other stuff

Versatility and use cases of React useEffect hook

I have started riding the hooks hype bandwagon the first-day hooks made it into the stable 16.8 release. Worried about our sanity (and support from testing libraries) we didn't rewrite existing components with hooks, but I favor hooks when making significant changes and creating new components. It became apparent that the number of lines of code in hooks-enabled components is much lower and so the surface for bugs to hide is smaller.

A: Do you know what code doesn't have bugs?
B: ?
A: Deleted one.

I digress right now as this blogpost is not about mediocre humor or making your components less error-prone with hooks but on the versatility of a useEffect hook. I'm going to present how you can implement common constructs and patterns with hooks.

Throughout this blogpost, I'm going to use Leaflet library to manipulate our map, you don't need any prior experience with Leaflet to follow this tutorial. React components are by nature very declarative. We tell React what to render and React job is to figure out the optimal way to get to the desired DOM state. An interactive map seems to be a good example to showcase the flexibility of hooks due to the additional challenge of supporting an imperative Leaflet API.

Set state callback

Let's imagine we want to render a map only after the user clicks the "Toggle map" button. The information whether the user has clicked the "Toggle map" button is part of the component's state.

function App() {
  const [showMap, setShowMap] = useState(false);
  const mapElement = useRef(null);

  useEffect(() => {
    if (showMap) {
      const map = L.map(mapElement.current).setView([51.505, -0.09], 13);
      L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png")
        .addTo(map);
    }
  }, [showMap]);

  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: "1fr 1fr",
        height: "100vh"
      }}
    >
      <div>
        <button onClick={() => setShowMap(!showMap)}>Toggle map</button>
      </div>
      <div ref={mapElement} />
    </div>
  );
}

(check the example)

The showMap is an effect's dependency so the effect will react to the showMap changes. If you try to click "Hide map" button, the map will not disappear and clicking "Show map" again will result in the error "Map container is already initialized". This is expected, and we have to do some cleanup.

useEffect(() => {
  let map;  if (showMap) {
    map = L.map(mapElement.current).setView([51.505, -0.09], 13);
    L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(
      map
    );
  }
  return () => {    if (map) {      mapElement.current.classList.remove("leaflet-container");      map.remove();    }  };}, [showMap]);

(check the example)

The best place to remove map and leaflet-container class is a callback returned from the useEffect which executes both when showMap changes (but before next useEffect call) and before component unmounts. If we woud write class component, we would have to define a separate method and call it as a set state callback and within componentWillUnmount.

React to props change

We want our map to be a separate component, and so we want to perform effects when mounting and unmounting the Map component. By passing an empty array as a list of dependencies, we can emulate componentDidMount and componentWillUnmount. I want to emphasize it is just emulating as this is not a drop-in replacement for lifecycle methods although in simple, stateless cases it may look like it is.

function Map() {
  const mapElement = useRef(null);

  useEffect(() => {
    let map = L.map(mapElement.current).setView([51.505, -0.09], 13);
    L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(
      map
    );
    return () => {
      if (map) {
        mapElement.current.classList.remove("leaflet-container");
        map.remove();
      }
    };
  }, []);

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

function App() {
  const [showMap, setShowMap] = useState(false);

  return (
    <div
      style={{
        display: "grid",
        gridTemplateColumns: "1fr 1fr",
        height: "100vh"
      }}
    >
      <div>
        <button onClick={() => setShowMap(!showMap)}>Toggle map</button>
      </div>
      {showMap && <Map />}
    </div>
  );
}

(check the example)

Now, the goal is to make our Map component accept external zoom parameter via props and react to its changes.

function Map({ zoom }) {
  // ...

  useEffect(() => {
    map = L.map(mapElement.current).setView([51.505, -0.09], zoom);
    // ...
  }, []);

  useEffect(() => {
    map.setZoom(zoom);
  }, [zoom]);

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

(check the example)

Although it looks like it should kind of work, it doesn't. Clicking on "Bigger" or "Smaller" button results in error "Cannot read property setZoom of undefined". This is because functional components are not like classes, they don't have an instance, and their result cannot depend on side effects from previous calls (here initializing map variable). Let's fix it.

const map = useRef(null);
useEffect(() => {
  map.current = L.map(mapElement.current).setView([51.505, -0.09], zoom);  L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png").addTo(
    map.current
  );
  return () => {
    if (map.current) {      map.current.remove();    }
  };
}, []);

useEffect(() => {
  map.current.setZoom(zoom);}, [zoom]);

return <div ref={mapElement} />;

(check the example)

We can use useRef to persist the map instance beyond a single render.

Subscribe to events

Now we would like to have the option of adding markers to the map. Due to questionable software design decisions in our project, we need addMarker to be an imperative interface that we can call outside of the context of components' tree. addMarker emits an event to which the Map component subscribes and adds a marker with a tooltip containing a number - the order of the marker.

const mapEvents = new EventEmitter();

function Map({ zoom }) {
  useEffect(() => {
    function addMarker({ latLng }) {
      const number = markersCount + 1;
      setMarketsCount(number);
      L.marker(latLng)
        .bindTooltip(`#${number}`)
        .addTo(map.current);
    }

    mapEvents.addListener("addMarker", addMarker);

    return () => {
      mapEvents.removeListener("addMarker", addMarker);
    };
  }, []);

  // ...
}

function addMarker() {
  mapEvents.emit("addMarker", {
    latLng: [51.505 - 0.5 / 3 + Math.random() / 3,
              -0.09 - 0.5 / 3 + Math.random() / 3]
  });
}

(check the example)

Adding markers works but tooltips are broken. Every marker's tooltip is number #1. This is because we shouldn't subscribe only when component mounts and unsubscribe when it unmounts as in "class components way". An effect is a closure, in closure state never actually changed.

useEffect(() => {
  // ...
  mapEvents.addListener("addMarker", addMarker);

  return () => {
    mapEvents.removeListener("addMarker", addMarker);
  };
}, [markersCount]);

(check the example)

The rule of thumb to get this right is to define the effect's dependencies correctly. This way, React can call a new effect once the markerCount changes.

Wrap Up

I hope you have learned something interesting that helped you to get a better understanding of React hooks and if not, the upside is that you're pretty good with React hooks already!

If you want to read more about why useEffect is not like componentDidMount and componentWillUnmount go and read Dan Abramov's post on useEffect hook.

Photo by Barn Images on Unsplash.