Getting more complicated with React Hooks

In my last post I gave a brief overview of the many tools React Hooks provides which allow developers to create stateful functional components that can mimic the the lifecycle events of class components.

Building off of this, I'd like to now give an example of a more complex piece of UI build entirely using React Hooks to demonstrate how React Hooks might look in more complicated components.

The Component

In a recent project we had to implement a React component that the user could rotate clicking and dragging the mouse. Here's an example of how this functionality could be implemented using React classes.

class App extends Component {
  constructor() {
    super();

    this.box = null;

    this.state = {
      isActive: false,
      angle: 0,
      startAngle: 0,
      currentAngle: 0,
      boxCenterPoint: {}
    };
    this.getPositionFromCenter = this.getPositionFromCenter.bind(this);
    this.mouseDownHandler = this.mouseDownHandler.bind(this);
    this.mouseUpHandler = this.mouseUpHandler.bind(this);
    this.mouseMoveHandler = this.mouseMoveHandler.bind(this);
    this.deslectAll = this.deslectAll.bind(this);
  }

  // to avoid unwanted behaviour, deselect all text
  deslectAll() {
    if (document.selection) {
      document.selection.empty();
    } else if (window.getSelection) {
      window.getSelection().removeAllRanges();
    }
  }

  // method to get the positionof the pointer event relative to the center of the box
  getPositionFromCenter(e) {
    const { boxCenterPoint } = this.state;
    const fromBoxCenter = {
      x: e.clientX - boxCenterPoint.x,
      y: -(e.clientY - boxCenterPoint.y)
    };
    return fromBoxCenter;
  }

  mouseDownHandler(e) {
    e.stopPropagation();
    const fromBoxCenter = this.getPositionFromCenter(e);
    const newStartAngle =
      90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
    this.setState({
      startAngle: newStartAngle,
      isActive: true
    });
  }

  mouseUpHandler(e) {
    this.deslectAll();
    e.stopPropagation();
    const { isActive, angle, startAngle, currentAngle } = this.state;
    if (isActive) {
      const newCurrentAngle = currentAngle + (angle - startAngle);
      this.setState({
        isActive: false,
        currentAngle: newCurrentAngle
      });
    }
  }

  mouseMoveHandler(e) {
    const { isActive, currentAngle, startAngle } = this.state;
    if (isActive) {
      const fromBoxCenter = this.getPositionFromCenter(e);
      const newAngle =
        90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
      console;
      this.box.style.transform =
        "rotate(" +
        (currentAngle + (newAngle - (startAngle ? startAngle : 0))) +
        "deg)";
      this.setState({ angle: newAngle });
    } // active conditional
  }

  componentDidMount() {
    const boxPosition = this.box.getBoundingClientRect();
    // get the current center point
    const boxCenterX = boxPosition.left + boxPosition.width / 2;
    const boxCenterY = boxPosition.top + boxPosition.height / 2;

    // update the state
    this.setState({
      boxCenterPoint: { x: boxCenterX, y: boxCenterY }
    });
    // in case the event ends outside the box
    window.onmouseup = this.mouseUpHandler;

    window.onmousemove = this.mouseMoveHandler;
  }

  render() {
    return (
      <div className="row">
        <div className="col-12">
          <h2 className="text-center">React Rotation</h2>
          <p>Click and rotate the blue square.</p>
          <p>Sample for this question in StackOverflow:</p>
          <p>
            <a
              href="https://stackoverflow.com/questions/45193647/reactjs-rotate-functionality-not-working"
              target="_blank"
            >
              https://stackoverflow.com/questions/45193647/reactjs-rotate-functionality-not-working
            </a>
          </p>
          <div
            className="box"
            onMouseDown={this.mouseDownHandler}
            onMouseUp={this.mouseUpHandler}
            ref={div => (this.box = div)}
          >
            Rotate
          </div>
        </div>
      </div>
    );
  }
}

Try it here

Here we have a div called "box". When the user clicks on the box they can drag the mouse to rotate it. When they let go of the mouse button the box retains its new rotation. This functionality is created using a number of variables tracked in the component state and a ref used to set up the event handlers that rotate the box.

When the component is first mounted, we calculate its center point and store it in component state as boxCenterPoint.

When mouseDownHandler is fired we set the isActive state to true to enable the mouseMoveHandler to fire when the user moves the cursor. We also calculate the current angle of the cursor relative to the boxCenterPoint and store the value as startAngle to be used in determining how much we need to rotate the box when the user moves the mouse.

Next, mouseMoveHandler is called when the user moves the cursor after pressing down the mouse button. When the cursor moves, the new angle of the cursor from the center point of the box is calculated, and using the startAngle stored in state the app calculates how much it needs to rotate the box using the ref we set up.

And finally, when the user releases the mouse button mouseUpHandler is called which resets isActive to false and sets the new currentAngle to the current rotation of box calculated from the old currentAngle + the angle of rotation of the drag and drop action (the difference between angle and startAngle).

To summarize:
isActive - a boolean that limits when mouseDownHandler is fired
boxCenterPoint - the center coordinates of the box div
currentAngle - the rotation angle of the box when the user is not rotating it (when isActive = false)
startAngle - the angle of the cursor relative to the boxCenterPoint when mouseDownHandler is called
angle - the angle of the cursor relative to the boxCenterPoint when mouseMoveHandler is called (when isActive = true)

Switching To React Hooks

If you would like to simple see the React Hooks version of this component you can find the code at the end of this post. Otherwise, we're going to go through the process of copying this component step by step into a functional component.

Let’s begin with a simple presentational component

CSS:

.box {
  height: 100px;
  width: 100px;
  border-radius: 10px;
  cursor: pointer;
  background: blue;
  color: white;
  padding: 10px;
  display: flex;
  align-items: center;
  justify-content: center;
}

.box-container {
  display: flex;
  align-items: center;
  justify-content: center;
  height: 100vh;
}

Javascript:

import React from "react";
import ReactDOM from "react-dom";

import "./styles.css";

const App = () => {
  return (
    <div className="box-container">
      <div className="box">Rotate</div>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Try it out here

Using the original class component version of the rotating div as a reference point let's add all of the state variables next.

We can also pass a ref to the box div using useRef

import React, { useState, useRef } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

const App = () => {
  const box = useRef(null);

  const [isActive, setIsActive] = useState(false);
  const [angle, setAngle] = useState(0);
  const [startAngle, setStartAngle] = useState(0);
  const [currentAngle, setCurrentAngle] = useState(0);
  const [boxCenterPoint, setBoxCenterPoint] = useState({});

  return (
    <div className="box-container">
      <div
        className="box"
        ref={box}
      >
        Rotate
      </div>
    </div>
  );
}

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Now we have all the state we need to work with. To start, let's set the boxCenterPoint when the component first mounts. Here was the logic from the class component.

componentDidMount() {
  const boxPosition = this.box.getBoundingClientRect();
  // get the current center point
  const boxCenterX = boxPosition.left + boxPosition.width / 2;
  const boxCenterY = boxPosition.top + boxPosition.height / 2;

  // update the state
  this.setState({
    boxCenterPoint: { x: boxCenterX, y: boxCenterY }
  });
}

To mimic the componentDidMount lifecycle method we call useEffect and pass it an empty dependency array as a second argument. When the component mounts

useEffect(() => {
  const boxPosition = box.current.getBoundingClientRect();
  // get the current center point
  const boxCenterX = boxPosition.left + boxPosition.width / 2;
  const boxCenterY = boxPosition.top + boxPosition.height / 2;

  // update the state
  setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
}, []);

This will set the boxCenterPoint when the component first mounts using the setBoxCenterPoint method returned from useState.

Now we can copy over the mouseDownHandler and getPositionFromCenter.

import React, { useState, useEffect, useRef } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

const App = () => {
  const box = useRef(null);

  const [isActive, setIsActive] = useState(false);
  const [angle, setAngle] = useState(0);
  const [startAngle, setStartAngle] = useState(0);
  const [currentAngle, setCurrentAngle] = useState(0);
  const [boxCenterPoint, setBoxCenterPoint] = useState({});

  const getPositionFromCenter = e => {
    const fromBoxCenter = {
      x: e.clientX - boxCenterPoint.x,
      y: -(e.clientY - boxCenterPoint.y)
    };
    return fromBoxCenter;
  };

  const mouseDownHandler = e => {
    e.stopPropagation();
    const fromBoxCenter = getPositionFromCenter(e);
    const newStartAngle =
      90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
    setStartAngle(newStartAngle);
    setIsActive(true);
    console.log(newStartAngle);
  };

  useEffect(() => {
    const boxPosition = box.current.getBoundingClientRect();
    // get the current center point
    const boxCenterX = boxPosition.left + boxPosition.width / 2;
    const boxCenterY = boxPosition.top + boxPosition.height / 2;

    // update the state
    setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
  }, []);

  return (
    <div className="box-container">
      <div className="box" onMouseDown={mouseDownHandler} ref={box}>
        Rotate
      </div>
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Try it out here

With this we have a component that will log the angle of the cursor relative to the center of the box when the user clicks on the box.

Now let's try something more complicated. Let's set up the mouseMoveHandler. To do this we'll have to set up an event listener that fires whenever the user moves the mouse.

Unlike in the class component, we cannot simply set up this listener one time when the component first mounts. As explained in the previous React Hooks post, Effects only access the state value from the render they immediately follow.

Here's a test mouseMoveHandler that should only log when the mouse moves and the isActive state is true.

const mouseMoveHandler = e => {
  if (isActive) {
    console.log("mouseMoveHandler does something");
  }
};

If we create this Effect to create an event listener when the component mounts,

useEffect(() => {
  window.onmousemove = mouseMoveHandler;
}, []);

then mouseMoveHandler will only ever be called with the initial value of isActive which is false. Nothing will ever happen.

Try it out here

To fix this, we should add mouseMoveHandler to the Effect's dependency array

useEffect(() => {
  window.onmousemove = mouseMoveHandler;
}, [mouseMoveHandler]);

Now this Effect will update the onmousemove event listener whenever mouseMoveHandler is updated. This is a step in the right direction, but there's one issue we still need to address. Currently mouseMoveHandler will update on every single rerender which will update the Effect on every single rerender. The Effect we've made is the exact same as this Effect without a dependency array.

useEffect(() => {
  window.onmousemove = mouseMoveHandler;
});

React gives us useCallback to limit when methods are updated in the exact same way we limit when Effects run with useEffect. Here's how we limit when mouseMoveHandler updates.

const mouseMoveHandler = useCallback(
  e => {
    if (isActive) {
      console.log("mouseMoveHandler does something");
    }
  },
  [isActive]
);

useCallback accepts 2 arguments, a callback that performs all the intended logic of the method, and a dependency array. It returns a memoized version of the callback that will only update when the variables specified in the dependency array update.

With this mouseMoveHandler will finally log its message when you move the cursor after you click on the box. In fact, it will not stop logging its message after you click on the box since we have yet to create a mouseUpHandler that will set isActive to false.

Let's do that now..

import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

const App = () => {
  const box = useRef(null);

  const [isActive, setIsActive] = useState(false);
  const [angle, setAngle] = useState(0);
  const [startAngle, setStartAngle] = useState(0);
  const [currentAngle, setCurrentAngle] = useState(0);
  const [boxCenterPoint, setBoxCenterPoint] = useState({});

  const getPositionFromCenter = e => {
    const fromBoxCenter = {
      x: e.clientX - boxCenterPoint.x,
      y: -(e.clientY - boxCenterPoint.y)
    };
    return fromBoxCenter;
  };

  const mouseDownHandler = e => {
    e.stopPropagation();
    const fromBoxCenter = getPositionFromCenter(e);
    const newStartAngle =
      90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
    setStartAngle(newStartAngle);
    setIsActive(true);
  };

  const mouseUpHandler = useCallback(
    e => {
      e.stopPropagation();
      if (isActive) {
        setIsActive(false);
      }
    },
    [isActive]
  );

  const mouseMoveHandler = useCallback(
    e => {
      if (isActive) {
        console.log("mouseMoveHandler does something");
      }
    },
    [isActive]
  );

  useEffect(() => {
    const boxPosition = box.current.getBoundingClientRect();
    // get the current center point
    const boxCenterX = boxPosition.left + boxPosition.width / 2;
    const boxCenterY = boxPosition.top + boxPosition.height / 2;

    // update the state
    setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
  }, []);

  useEffect(() => {
    // in case the event ends outside the box
    window.onmouseup = mouseUpHandler;
    window.onmousemove = mouseMoveHandler;
  }, [mouseUpHandler, mouseMoveHandler]);

  return (
    <div className="box-container">
      <div className="box" onMouseDown={mouseDownHandler} ref={box}>
        Rotate
      </div>
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Try it out here

Note that we've added mouseUpHandler to the Effect's dependency array. With this the mouseMoveHandler will only fire when the use has the mouse button held down after clicking on the box.

Let's get the box rotating!

Now that we have our event listeners set up, let's copy over the logic for mouseMoveHandler and mouseUpHandler from our class component template. With this our rotating div functional component will be complete.

import React, { useState, useEffect, useCallback, useRef } from "react";
import ReactDOM from "react-dom";

import "./styles.css";

const App = () => {
  const box = useRef(null);

  const [isActive, setIsActive] = useState(false);
  const [angle, setAngle] = useState(0);
  const [startAngle, setStartAngle] = useState(0);
  const [currentAngle, setCurrentAngle] = useState(0);
  const [boxCenterPoint, setBoxCenterPoint] = useState({});

  // to avoid unwanted behaviour, deselect all text
  const deselectAll = () => {
    if (document.selection) {
      document.selection.empty();
    } else if (window.getSelection) {
      window.getSelection().removeAllRanges();
    }
  };

  // method to get the positionof the pointer event relative to the center of the box
  const getPositionFromCenter = useCallback(
    e => {
      const fromBoxCenter = {
        x: e.clientX - boxCenterPoint.x,
        y: -(e.clientY - boxCenterPoint.y)
      };
      return fromBoxCenter;
    },
    [boxCenterPoint]
  );

  const mouseDownHandler = e => {
    e.stopPropagation();
    const fromBoxCenter = getPositionFromCenter(e);
    const newStartAngle =
      90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
    setStartAngle(newStartAngle);
    setIsActive(true);
  };

  const mouseUpHandler = useCallback(
    e => {
      deselectAll();
      e.stopPropagation();
      if (isActive) {
        const newCurrentAngle = currentAngle + (angle - startAngle);
        setIsActive(false);
        setCurrentAngle(newCurrentAngle);
      }
    },
    [isActive, angle, currentAngle, startAngle]
  );

  const mouseMoveHandler = useCallback(
    e => {
      if (isActive) {
        const fromBoxCenter = getPositionFromCenter(e);
        const newAngle =
          90 - Math.atan2(fromBoxCenter.y, fromBoxCenter.x) * (180 / Math.PI);
        box.current.style.transform =
          "rotate(" +
          (currentAngle + (newAngle - (startAngle ? startAngle : 0))) +
          "deg)";
        setAngle(newAngle);
      }
    },
    [isActive, currentAngle, startAngle, getPositionFromCenter]
  );

  useEffect(() => {
    const boxPosition = box.current.getBoundingClientRect();
    // get the current center point
    const boxCenterX = boxPosition.left + boxPosition.width / 2;
    const boxCenterY = boxPosition.top + boxPosition.height / 2;

    // update the state
    setBoxCenterPoint({ x: boxCenterX, y: boxCenterY });
  }, []);

  useEffect(() => {
    // in case the event ends outside the box
    window.onmouseup = mouseUpHandler;
    window.onmousemove = mouseMoveHandler;
  }, [mouseUpHandler, mouseMoveHandler]);

  return (
    <div className="box-container">
      <div
        className="box"
        onMouseDown={mouseDownHandler}
        onMouseUp={mouseUpHandler}
        ref={box}
      >
        Rotate
      </div>
    </div>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Try it out here

As a final note, pay attention to the dependency arrays for the mouseUpHandler and mouseMoveHandler. As a general rule, it is always best to specify every single prop, method, and/or piece of state that a method or Effect will use in the dependency array of useCallback or useEffect so that the methods and Effects can be updated appropriately when the component rerenders. The Eslint rule react-hooks/exhaustive-deps can be helpful in identifying parts of your code where you may have missed a dependency in your dependency array.