How to register only once in react useEffect and get useState changes

Viewed 476

I have a react hook component which needs to register to an outside event as follows:

const DefaultRateSection: React.FC<{
    registerOnSaveEvent: (fn: () => void) => void;
  }> = ({registerOnSaveEvent}) => {
    const [serviceName, setServiceName] = useState('the name');
    const serviceNameInput = useRef<input>(null);
    
    useEffect(
      () => {
        return registerOnSaveEvent(() => {
          if (!serviceName) {
            serviceNameInput.current?.focus();
          }
        });
      },
      [registerOnSaveEvent]
    );
    return (
        <input
        ref={serviceNameInput}
        value={serviceName}
        onChange={(event) => {
            const newValue = event.target.value;
            setServiceName(newValue)
        }}
        />
    );
  };

The registerOnSaveEvent is an API that i cannot change and i do not have an unsubscribe method, therefore i need to register to it only once. However, when it fires (from outside the component) i'm receiving the initial value of serviceName and not the updated one. I know it happens because i'm not calling useEffect after the change, but I need to avoid multiple registrations.

How can i achieve this?

1 Answers

TL-TR: The short answer is to use another ref so that the arrow function can access the latest rendered value of service name.

const serviceNameRef = useRef();
serviceNameRef.current = serviceName; 
// use serviceNameRef.current in the arrow function

This code will NOT work

// Get a hook function - only needed for this Stack Snippet
const {useState, useRef, useEffect} = React;


const DefaultRateSection = ({ registerOnSaveEvent }) => {
  const [serviceName, setServiceName] = useState("the name");
  const serviceNameInput = useRef();
  
  useEffect(() => {
    return registerOnSaveEvent(() => {
      console.log(serviceName)
    });
  }, [registerOnSaveEvent]);
  
  return (
    <input
      ref={serviceNameInput}
      value={serviceName}
      onChange={(event) => {
        const newValue = event.target.value;
        setServiceName(newValue);
      }}
    />
  );
};

const App = () => {
  const callbackRef = useRef();
  
  function registerOnSaveEvent(callback) {
    callbackRef.current=callback
  }
  
  function execCallback() {
    callbackRef.current();
  }

  return <div>
    <h2>This will not work</h2>
    <p>Try to change the input field and click 'RegisterOnSaveEvent'.</p>
    <p>The callback will not see the new value of the input</p>
    <DefaultRateSection registerOnSaveEvent={registerOnSaveEvent}/>
    <button onClick={execCallback}>RegisterOnSaveEvent</button>
  </div>;
};


ReactDOM.render(
  <App/>,
  document.getElementById("react")
);
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>

This code will work

// Get a hook function - only needed for this Stack Snippet
const {useState, useRef, useEffect} = React;


const DefaultRateSection = ({ registerOnSaveEvent }) => {
  const [serviceName, setServiceName] = useState("the name");
  const serviceNameInput = useRef();

  // use a ref for the service name and
  // update it with the serviceName state on every render
  const serviceNameRef = useRef();
  serviceNameRef.current = serviceName;
  
  useEffect(() => {
    return registerOnSaveEvent(() => {
      console.log(serviceNameRef.current)
    });
  }, [registerOnSaveEvent]);
  
  return (
    <input
      ref={serviceNameInput}
      value={serviceName}
      onChange={(event) => {
        const newValue = event.target.value;
        setServiceName(newValue);
      }}
    />
  );
};

const App = () => {
  const callbackRef = useRef();
  
  function registerOnSaveEvent(callback) {
    callbackRef.current=callback
  }
  
  function execCallback() {
    callbackRef.current();
  }

  return <div>
    <h2>This will work</h2>
    <p>Try to change the input field and click 'RegisterOnSaveEvent'.</p>
    <p>The callback will not see the new value of the input</p>
    <DefaultRateSection registerOnSaveEvent={registerOnSaveEvent}/>
    <button onClick={execCallback}>RegisterOnSaveEvent</button>
  </div>;
};


ReactDOM.render(
  <App/>,
  document.getElementById("react")
);
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>
<div id="react"></div>

Explanation

When the useEffect is executed it creates an arrow function. This arrow function references the const serviceName which is the initial value. This is the value that the arrow function sees. When you enter something in the input field you call the setServiceName which changes the state and triggers a rerender. The rendering itself is nothing but a function call. So when the component is rerendered the useState returns the state and you assign it to a brand new local const named serviceName. This is NOT the same as the one that the arrow function references. Thus the arrow function will always see the value of serviceName when it was created.

To solve this problem I use another ref for the serviceName called serviceNameRef and update that ref with the serviceName state on every rendering. Since useRef returns the same instance of serviceRefName on each call, it is the same instance as the one the arrow function uses. That's how it works.

Related