javascriptreactjsreact-functional-component

Functions not using most recent state in React functional component


I am converting a class component to a functional component but I am having an issue where state values from useState are not the most recent values inside a another function. Because the functions need to be called dynamically, they are first defined in a mapping to an ID and invoked using the current ID. Before, I solved this by binding the validator functions to the component itself. However as a functional component, this is not possible.

Here is a simplified version of the issue:

export default function App(props) {
  const [currentStage, setStage] = useState(0);
  const [stageOrder, setStageOrder] = useState([]);
  const [inputName, setInput] = useState(null);
  const [invalid, setInvalid] = useState(false);

  const stageDefinitions = {
    [StageType.ONE]: { id: StageType.ONE, validator: validateOne },
    [StageType.TWO]: { id: StageType.TWO, validator: validateTwo },
  };

  useEffect(() => {
    // written like this for readability and make it easier to change the order in the future
    let order = props.flag ? [StageType.ONE, StageType.TWO] : [StageType.ONE];

    order = order.map((s) => stageDefinitions[s]);
    setStageOrder(order);
  }, []);

  function nextStage() {
    if (currentStage === stageOrder.length - 1) return;

    const stage = stageOrder[currentStage];
    const isValid = stage.validator();

    if (isValid) setStage(currentStage + 1);
    setInvalid(!isValid);
  }

  function validateOne() {
    // problem is here; inputName remains null when called from nextStage
    console.log("inputName", inputName);
    return inputName && inputName.length > 1;
  }

  function validateTwo() {
    return props.two > 1;
  }

  function handleClick() {
    setInput(`${Math.random()}`);
  }

  return (
    <div>
      <div>Value: {inputName}</div>
      {invalid && <div style={{ color: "red" }}>INVALID</div>}

      <div>Stage: {currentStage}</div>

      <button onClick={handleClick}>Set Value</button>
      <button onClick={nextStage}>Next Stage</button>
    </div>
  );
}

This can also be found on codesandbox.

I've tried using useCallback and defining stageDefinitions in the state. The ultimate goal is that when a validator is called from nextStage, it uses the current value of inputName.


Solution

  • When you use the state inside a function like that, it will always have only the initial state, because when the component rerender, it changes the reference. One way to fix it is to pass the state as a parameter from the onClick, where it is always up to date:

    import React, { useState, useEffect } from "react";
    import "./styles.css";
    
    const StageType = {
      ONE: 1,
      TWO: 2,
    };
    
    export default function App(props) {
      const [currentStage, setStage] = useState(0);
      const [stageOrder, setStageOrder] = useState([]);
      const [inputName, setInput] = useState(null);
      const [invalid, setInvalid] = useState(false);
    
      const stageDefinitions = {
        [StageType.ONE]: { id: StageType.ONE, validator: validateOne },
        [StageType.TWO]: { id: StageType.TWO, validator: validateTwo },
      };
    
      useEffect(() => {
        // written like this for readability and make it easier to change the order in the future
        let order = props.flag ? [StageType.ONE, StageType.TWO] : [StageType.ONE];
    
        order = order.map((s) => stageDefinitions[s]);
        setStageOrder(order);
      }, []);
    
      function nextStage(input) {
        if (currentStage === stageOrder.length - 1) return;
    
        const stage = stageOrder[currentStage];
        const isValid = stage.validator(input);
        if (isValid) setStage(currentStage + 1);
        setInvalid(!isValid);
      }
    
      function validateOne(input) {
        return input && input.length > 1;
      }
    
      function validateTwo() {
        return props.two > 1;
      }
    
      function handleClick() {
        setInput(`${Math.random()}`);
      }
    
      return (
        <div>
          <div>Value: {inputName}</div>
          {invalid && <div style={{ color: "red" }}>INVALID</div>}
    
          <div>Stage: {currentStage}</div>
    
          <button onClick={handleClick}>Set Value</button>
          <button
            onClick={() => {
              nextStage(inputName);
            }}
          >
            Next Stage
          </button>
        </div>
      );
    }
    

    Other way if you don't need to trigger a rerender everytime is to use a ref:

    import React, { useState, useEffect, useRef } from "react";
    import "./styles.css";
    
    const StageType = {
      ONE: 1,
      TWO: 2,
    };
    
    export default function App(props) {
      const [currentStage, setStage] = useState(0);
      const [stageOrder, setStageOrder] = useState([]);
      const inputNameRef = useRef(null);
      const [invalid, setInvalid] = useState(false);
    
      const stageDefinitions = {
        [StageType.ONE]: { id: StageType.ONE, validator: validateOne },
        [StageType.TWO]: { id: StageType.TWO, validator: validateTwo },
      };
    
      useEffect(() => {
        // written like this for readability and make it easier to change the order in the future
        let order = props.flag ? [StageType.ONE, StageType.TWO] : [StageType.ONE];
    
        order = order.map((s) => stageDefinitions[s]);
        setStageOrder(order);
      }, []);
    
      function nextStage() {
        if (currentStage === stageOrder.length - 1) return;
    
        const stage = stageOrder[currentStage];
        const isValid = stage.validator();
        if (isValid) setStage(currentStage + 1);
        setInvalid(!isValid);
      }
    
      function validateOne() {
        return inputNameRef.current && inputNameRef.current.length > 1;
      }
    
      function validateTwo() {
        return props.two > 1;
      }
    
      function handleClick() {
        inputNameRef.current = `${Math.random()}`;
      }
    
      return (
        <div>
          <div>Value: {inputNameRef.current}</div>
          {invalid && <div style={{ color: "red" }}>INVALID</div>}
    
          <div>Stage: {currentStage}</div>
    
          <button onClick={handleClick}>Set Value</button>
          <button onClick={nextStage}>Next Stage</button>
        </div>
      );
    }