简体   繁体   中英

React State Issues with Classful to Functional Component Migration

Problem

I'm converting a classful component into a functional component and state re-rendering has some issues.

Here is the original classful component:

class Skills extends React.Component {
  constructor(props) {
    super(props);
    const skills = [
      "HTML",
      "CSS",
      "SCSS",
      "Python",
      "JavaScript",
      "TypeScript",
      "Dart",
      "C++",
      "ReactJS",
      "Angular",
      "VueJS",
      "Flutter",
      "npm",
      "git",
      "pip",
      "Github",
      "Firebase",
      "Google Cloud",
    ];
    this.state = {
      skills: skills.sort(() => 0.5 - Math.random()),
      isLoaded: false,
      points: new Array(skills.length).fill([[0], [0], [-200]]),
      sphereLimit: 1,
      xRatio: Math.random() / 2,
      yRatio: Math.random() / 2,
      isMounted: true,
    };
  }

  fibSphere(samples = this.state.skills.length) {
    // https://stackoverflow.com/a/26127012/10472451
    const points = [];
    const phi = pi * (3 - sqrt(5));

    for (let i = 0; i < samples; i++) {
      const y = (i * 2) / samples - 1;
      const radius = sqrt(1 - y * y);

      const theta = phi * i;

      const x = cos(theta) * radius;
      const z = sin(theta) * radius;

      const itemLimit = this.state.sphereLimit * 0.75;

      points.push([[x * itemLimit], [y * itemLimit], [z * itemLimit]]);
    }
    this.setState({
      points: points,
      isLoaded: true,
    });
  }

  rotateSphere(samples = this.state.skills.length) {
    const newPoints = [];

    const thetaX = unit(-this.state.yRatio * 10, "deg");
    const thetaY = unit(this.state.xRatio * 10, "deg");
    const thetaZ = unit(0, "deg");

    const rotationMatrix = multiply(
      matrix([
        [1, 0, 0],
        [0, cos(thetaX), -sin(thetaX)],
        [0, sin(thetaX), cos(thetaX)],
      ]),
      matrix([
        [cos(thetaY), 0, sin(thetaY)],
        [0, 1, 0],
        [-sin(thetaY), 0, cos(thetaY)],
      ]),
      matrix([
        [cos(thetaZ), -sin(thetaZ), 0],
        [sin(thetaZ), cos(thetaZ), 0],
        [0, 0, 1],
      ])
    );

    for (let i = 0; i < samples; i++) {
      const currentPoint = this.state.points[i];
      const newPoint = multiply(rotationMatrix, currentPoint)._data;

      newPoints.push(newPoint);
    }

    if (this.state.isMounted) {
      this.setState({ points: newPoints });
      setTimeout(() => {
        this.rotateSphere();
      }, 100);
    }
  }

  handleMouseMove(e) {
    let xPosition = e.clientX;
    let yPosition = e.clientY;

    if (e.type === "touchmove") {
      xPosition = e.touches[0].pageX;
      yPosition = e.touches[0].pageY;
    }

    const spherePosition = document
      .getElementById("sphere")
      .getBoundingClientRect();

    const xDistance = xPosition - spherePosition.width / 2 - spherePosition.x;
    const yDistance = yPosition - spherePosition.height / 2 - spherePosition.y;

    const xRatio = xDistance / this.state.sphereLimit;
    const yRatio = yDistance / this.state.sphereLimit;

    this.setState({
      xRatio: xRatio,
      yRatio: yRatio,
    });
  }

  updateWindowDimensions() {
    try {
      const sphere = document.getElementById("sphere");

      if (
        this.state.sphereLimit !==
        Math.min(sphere.clientHeight, sphere.clientWidth) / 2
      ) {
        this.setState({
          sphereLimit: Math.min(sphere.clientHeight, sphere.clientWidth) / 2,
        });
        this.fibSphere();
      }
    } catch (error) {
      console.error(error);
    }
  }

  componentDidMount() {
    document.title =
      window.location.pathname === "/skills"
        ? "Josh Pollard | ⚙️"
        : document.title;

    setTimeout(() => {
      this.fibSphere();
      this.updateWindowDimensions();
      this.rotateSphere();
    }, 1500);

    window.addEventListener("resize", () => this.updateWindowDimensions());
  }

  componentWillUnmount() {
    this.setState({ isMounted: false });
    window.removeEventListener("resize", () => this.updateWindowDimensions());
  }

  render() {
    return (
      <motion.div
        className="skills-body"
        initial="initial"
        animate="animate"
        exit="exit"
        custom={window}
        variants={pageVariants}
        transition={pageTransition}
        onMouseMove={(e) => this.handleMouseMove(e)}
        onTouchMove={(e) => this.handleMouseMove(e)}
      >
        <div className="skills-info-container">
          <div className="skills-title">Skills</div>
          <div className="skills-description">
            I am a driven and passionate aspiring software engineer. I have
            invested a significant amount of time and effort in self-teaching,
            developing my knowledge and supporting others in the field of
            digital technology. I thrive on the challenge of finding intelligent
            solutions to complex problems and I am keen to apply and grow my
            skills in the workplace.
          </div>
        </div>
        <div className="sphere-container" id="sphere">
          {this.state.isLoaded &&
            this.state.skills.map((skill, index) => (
              <motion.div
                className="sphere-item"
                key={index}
                initial={{ opacity: 0 }}
                animate={{
                  x: this.state.points[index][0][0],
                  y: this.state.points[index][1][0] - 20,
                  z: this.state.points[index][2][0],
                  opacity: Math.max(
                    (this.state.points[index][2][0] / this.state.sphereLimit +
                      1) /
                      2,
                    0.1
                  ),
                }}
                transition={{
                  duration: 0.1,
                  ease: "linear",
                }}
              >
                {skill}
              </motion.div>
            ))}
        </div>
      </motion.div>
    );
  }
}

It's essentially a sphere of words that moves depending on mouse movement demo

Now this is as far as I have gotten with the migration to a Functional component:

function Skills(props) {
  const skills = [
    "HTML",
    "CSS",
    "SCSS",
    "Python",
    "JavaScript",
    "TypeScript",
    "Dart",
    "C++",
    "ReactJS",
    "Angular",
    "VueJS",
    "Flutter",
    "npm",
    "git",
    "pip",
    "Github",
    "Firebase",
    "Google Cloud",
  ].sort(() => 0.5 - Math.random());

  const [points, setPoints] = useState(
    new Array(skills.length).fill([0, 0, -200])
  );
  const [sphereLimit, setSphereLimit] = useState(1);
  const [xRatio, setXRatio] = useState(Math.random() / 2);
  const [yRatio, setYRatio] = useState(Math.random() / 2);
  const [isLoaded, setIsLoaded] = useState(false);

  useEffect(() => {
    document.title =
      window.location.pathname === "/skills"
        ? "Josh Pollard | ⚙️"
        : document.title;

    let interval;
    setTimeout(() => {
        updateWindowDimensions();
        interval = setInterval(rotateSphere, 100);
    }, 1500);
    window.addEventListener("resize", updateWindowDimensions);

    return () => {
      clearInterval(interval);
      window.removeEventListener("resize", updateWindowDimensions);
    };
  }, []); // eslint-disable-line react-hooks/exhaustive-deps

  const fibSphere = (samples = skills.length) => {
    // https://stackoverflow.com/a/26127012/10472451
    const newPoints = [];
    const phi = pi * (3 - sqrt(5));

    for (let i = 0; i < samples; i++) {
      const y = (i * 2) / samples - 1;
      const radius = sqrt(1 - y * y);

      const theta = phi * i;

      const x = cos(theta) * radius;
      const z = sin(theta) * radius;

      const itemLimit = sphereLimit * 0.75;

      newPoints.push([x * itemLimit, y * itemLimit, z * itemLimit]);
    }
    console.log(newPoints);
    setPoints(newPoints);
    setIsLoaded(true);
  };

  const rotateSphere = (samples = skills.length) => {
    const newPoints = [];

    const thetaX = unit(-yRatio * 10, "deg");
    const thetaY = unit(xRatio * 10, "deg");
    const thetaZ = unit(0, "deg");

    const rotationMatrix = multiply(
      matrix([
        [1, 0, 0],
        [0, cos(thetaX), -sin(thetaX)],
        [0, sin(thetaX), cos(thetaX)],
      ]),
      matrix([
        [cos(thetaY), 0, sin(thetaY)],
        [0, 1, 0],
        [-sin(thetaY), 0, cos(thetaY)],
      ]),
      matrix([
        [cos(thetaZ), -sin(thetaZ), 0],
        [sin(thetaZ), cos(thetaZ), 0],
        [0, 0, 1],
      ])
    );

    for (let i = 0; i < samples; i++) {
      const currentPoint = points[i];
      const newPoint = multiply(rotationMatrix, currentPoint)._data;

      newPoints.push(newPoint);
    }
    console.log(newPoints[0]);
    console.log(points[0]);
    setPoints(newPoints);
  };

  const handleMouseMove = (e) => {
    let xPosition = e.clientX;
    let yPosition = e.clientY;

    if (e.type === "touchmove") {
      xPosition = e.touches[0].pageX;
      yPosition = e.touches[0].pageY;
    }

    const spherePosition = document
      .getElementById("sphere")
      .getBoundingClientRect();

    const xDistance = xPosition - spherePosition.width / 2 - spherePosition.x;
    const yDistance = yPosition - spherePosition.height / 2 - spherePosition.y;

    const xRatio = xDistance / sphereLimit;
    const yRatio = yDistance / sphereLimit;

    setXRatio(xRatio);
    setYRatio(yRatio);
  };

  const updateWindowDimensions = () => {
    try {
      const sphere = document.getElementById("sphere");

      if (
        sphereLimit !==
        Math.min(sphere.clientHeight, sphere.clientWidth) / 2
      ) {
        setSphereLimit(Math.min(sphere.clientHeight, sphere.clientWidth) / 2);
        fibSphere();
      }
    } catch (error) {
      console.error(error);
    }
  };

  return (
    <motion.div
      className="skills-body"
      initial="initial"
      animate="animate"
      exit="exit"
      custom={window}
      variants={pageVariants}
      transition={pageTransition}
      onMouseMove={handleMouseMove}
      onTouchMove={handleMouseMove}
    >
      <div className="skills-info-container">
        <div className="skills-title">Skills</div>
        <div className="skills-description">
          I am a driven and passionate aspiring software engineer. I have
          invested a significant amount of time and effort in self-teaching,
          developing my knowledge and supporting others in the field of digital
          technology. I thrive on the challenge of finding intelligent solutions
          to complex problems and I am keen to apply and grow my skills in the
          workplace.
        </div>
      </div>
      <div className="sphere-container" id="sphere">
        {isLoaded &&
          skills.map((skill, index) => (
            <motion.div
              className="sphere-item"
              key={index}
              initial={{ opacity: 0 }}
              animate={{
                x: points[index][0],
                y: points[index][1] - 20,
                z: points[index][2],
                opacity: Math.max(
                  (points[index][2] / sphereLimit + 1) / 2,
                  0.1
                ),
              }}
              transition={{
                duration: 0.1,
                ease: "linear",
              }}
            >
              {skill}
            </motion.div>
          ))}
      </div>
    </motion.div>
  );
}

Investigation

Now when I run this functional version it seems that for every state update the component is 'reset', instead of updating the UI, here is a codesandbox env

When highlighting one of the 'skill' words in the browser, it seems to be switching length very quickly (every 100ms, the same interval as the rotation sphere). This can be confirmed by going into dev tools and seeing that each 'skill' word changes every 100ms.

Unless I've got this wrong, this doesn't seem right at all. The skills variable in the functional component is a const so shouldn't change on state changes?

I feel like I'm missing something very obvious, any help appreciated!

Here is a working piece (functional component) of your class component:

import React, { useEffect, useRef, useState } from 'react'
import { motion } from 'framer-motion'
import { matrix, multiply, sin, cos, sqrt, pi, unit } from 'mathjs'
import './skills.scss'

const pageTransition = {
  ease: [0.94, 0.06, 0.88, 0.45],
  duration: 1,
  delay: 0.5,
}

const pageVariants = {
  initial: (window) => ({
    position: 'fixed',
    clipPath: `circle(0px at ${window.innerWidth / 2}px ${
      window.innerHeight / 2
    }px)`,
  }),
  animate: (window) => ({
    clipPath: `circle(${
      Math.max(window.innerWidth, window.innerHeight) * 4
    }px at ${window.innerWidth / 2}px ${window.innerHeight / 2}px)`,
    position: 'absolute',
  }),
  exit: {
    display: 'fixed',
  },
}

const skills = [
  'HTML',
  'CSS',
  'SCSS',
  'Python',
  'JavaScript',
  'TypeScript',
  'Dart',
  'C++',
  'ReactJS',
  'Angular',
  'VueJS',
  'Flutter',
  'npm',
  'git',
  'pip',
  'Github',
  'Firebase',
  'Google Cloud',
]

export default function Skills() {
  const sphere = useRef(undefined)

  const [isLoaded, setIsLoaded] = useState(true)
  const [points, setPoints] = useState(
    new Array(skills.length).fill([[0], [0], [-200]]),
  )
  const [sphereLimit, setSphereLimit] = useState(1)
  const [xRatio, setXRatio] = useState(Math.random() / 2)
  const [yRatio, setYRatio] = useState(Math.random() / 2)
  const [triggerBy, setTriggerBy] = useState('')

  const [sphereItem, setSphereItem] = useState([])

  useEffect(() => {
    if (triggerBy === 'fibSphere') {
      rotateSphere()
    }

    if (triggerBy === 'rotateSphere') {
      setTimeout(() => {
        rotateSphere()
      }, 100)
    }
  })

  useEffect(() => {
    fibSphere()
    updateWindowDimensions()
    //rotateSphere();
  }, [sphereItem])

  const fibSphere = () => {
    // https://stackoverflow.com/a/26127012/10472451
    let newPoints = []
    const phi = pi * (3 - sqrt(5))

    for (let i = 0; i < skills.length; i++) {
      const y = (i * 2) / skills.length - 1
      const radius = sqrt(1 - y * y)

      const theta = phi * i

      const x = cos(theta) * radius
      const z = sin(theta) * radius

      const itemLimit = sphereLimit * 0.75

      newPoints.push([[x * itemLimit], [y * itemLimit], [z * itemLimit]])
    }
    setPoints(newPoints)
    setIsLoaded(true)
    setTriggerBy('fibSphere')
  }

  const rotateSphere = () => {
    let newPoints = []

    const thetaX = unit(-1 * yRatio * 10, 'deg')
    const thetaY = unit(xRatio * 10, 'deg')
    const thetaZ = unit(0, 'deg')

    const rotationMatrix = multiply(
      matrix([
        [1, 0, 0],
        [0, cos(thetaX), -sin(thetaX)],
        [0, sin(thetaX), cos(thetaX)],
      ]),
      matrix([
        [cos(thetaY), 0, sin(thetaY)],
        [0, 1, 0],
        [-sin(thetaY), 0, cos(thetaY)],
      ]),
      matrix([
        [cos(thetaZ), -sin(thetaZ), 0],
        [sin(thetaZ), cos(thetaZ), 0],
        [0, 0, 1],
      ]),
    )

    for (let i = 0; i < skills.length; i++) {
      const currentPoint = points[i]
      const newPoint = multiply(rotationMatrix, currentPoint)._data

      newPoints.push(newPoint)
    }

    setPoints(newPoints)
    setTriggerBy('rotateSphere')
  }

  const updateWindowDimensions = () => {
    console.log('sphere', sphere)
    try {
      if (
        sphereLimit !==
        Math.min(sphere.current.clientHeight, sphere.current.clientWidth) / 2
      ) {
        setSphereLimit(
          Math.min(sphere.current.clientHeight, sphere.current.clientWidth) / 2,
        )
        fibSphere()
      }
    } catch (error) {
      console.error(error)
    }
  }

  const handleMouseMove = (e) => {
    let xPosition = e.clientX
    let yPosition = e.clientY

    if (e.type === 'touchmove') {
      xPosition = e.touches[0].pageX
      yPosition = e.touches[0].pageY
    }

    const spherePosition = document
      .getElementById('sphere')
      .getBoundingClientRect()

    const xDistance = xPosition - spherePosition.width / 2 - spherePosition.x
    const yDistance = yPosition - spherePosition.height / 2 - spherePosition.y

    const _xRatio = xDistance / sphereLimit
    const _yRatio = yDistance / sphereLimit

    setXRatio(_xRatio)
    setYRatio(_yRatio)
    setTriggerBy('ratios')
  }

  const addSphereItems = (ref) => {
    setSphereItem((prev) => [...prev, ref])
  }

  return (
    <motion.div
      className="skills-body"
      initial="initial"
      animate="animate"
      exit="exit"
      custom={window}
      variants={pageVariants}
      transition={pageTransition}
      onMouseMove={handleMouseMove}
      onTouchMove={handleMouseMove}
    >
      <div className="skills-info-container">
        <div className="skills-title">Skills</div>
        <div className="skills-description">
          I am a driven and passionate aspiring software engineer. I have
          invested a significant amount of time and effort in self-teaching,
          developing my knowledge and supporting others in the field of digital
          technology. I thrive on the challenge of finding intelligent solutions
          to complex problems and I am keen to apply and grow my skills in the
          workplace.
        </div>
      </div>
      <div className="sphere-container" id="sphere" ref={sphere}>
        {isLoaded &&
          skills.map((skill, index) => {
            return (
              <motion.div
                ref={addSphereItems}
                className="sphere-item"
                key={index}
                initial={{ opacity: 0 }}
                animate={{
                  x:
                    sphereItem && sphereItem[index]
                      ? points[index][0][0] - sphereItem[index].clientWidth / 2
                      : points[index][0][0],

                  y: points[index][1][0] - 20,

                  z: points[index][2][0],
                  opacity: Math.max(
                    (points[index][2][0] / sphereLimit + 1) / 2,
                    0.1,
                  ),
                }}
                transition={{
                  duration: 0.1,
                  ease: 'linear',
                }}
              >
                {skill}
              </motion.div>
            )
          })}
      </div>
    </motion.div>
  )
}

You seemed to be loosing the points state value because you were triggering setPoints from different parts of the your code in the same cycle.

What functional component does is that it batches the updates for one refresh cycle.

So the problem was that the functions fibSphere and rotateSphere were called in the same cycle, the first function fibSphere althought did trigger setPoints but the value for points did not change, since the function called after ( rotateSphere ) also triggered setPoints . Both of these were batched.

So the order of operations, in order for your code to work was that once fibSphere completed setting points, then (and only then) should rotateSphere trigger and update points .

I introduced the useEffect (without dependancy array ie that triggers on each update) and made use of a state variable triggerBy to actually see when each update happens on points and execute the order of operations accordingly.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM