anon
anon

Reputation:

How to persist a variable between renders in React Hooks?

So, I've got a variable dog that I want to persist between re-renders.

const { useState, useEffect, useRef } = React;

class Animal {
  constructor(name) {
    this.name = name;
  }

  greet() {
    alert(`Hello I'm ${this.name}!`);
  }
}

const Dog = () => {
  let dog;
  useEffect(() => {
    dog = new Animal("Rusty", 5);
  }, []);
  return <button onClick={() => dog.greet()}>Greet</button>;
};

const App = () => {
  const [num, setNum] = useState(1);

  return (
    <main>
      <p>{num}</p>
      <button onClick={() => setNum(num + 1)}>Add 1</button>
      <br />
      <Dog />
    </main>
  );
};

ReactDOM.render(<App />, document.querySelector("#react-container"));
<div id="react-container"></div>

<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>

In the snippet above there's the dog variable that I want to persist between re-renders. On the first render Dog component works fine and the alert is triggered successfully on clicking the Greet button.

But once there's a re-render which can be forced by clicking the Add 1 button, the dog variable is reset and now there's no greet method on it, so clicking Greet button throws an error.

I fixed this by using the useRef hook, just wanted to know if there's some better alternative or useRef is the best practice here.

So, I modified the Dog component to the following:

const Dog = () => {
  let dog = useRef(null);
  useEffect(() => {
    dog.current = new Animal("Rusty", 5);
  }, []);
  return <button onClick={() => dog.current.greet()}>Greet</button>;
};

const { useState, useEffect, useRef } = React;

class Animal {
  constructor(name) {
    this.name = name;
  }

  greet() {
    alert(`Hello I'm ${this.name}!`);
  }
}

const Dog = () => {
  let dog = useRef(null);
  useEffect(() => {
    dog.current = new Animal("Rusty", 5);
  }, []);
  return <button onClick={() => dog.current.greet()}>Greet</button>;
};

const App = () => {
  const [num, setNum] = useState(1);

  return (
    <main>
      <p>{num}</p>
      <button onClick={() => setNum(num + 1)}>Add 1</button>
      <br />
      <Dog />
    </main>
  );
};

ReactDOM.render(<App />, document.querySelector("#react-container"));
<div id="react-container"></div>

<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>

Upvotes: 5

Views: 19782

Answers (2)

Som Shekhar Mukherjee
Som Shekhar Mukherjee

Reputation: 8158

Let's understand line by line what's happening with the first implementation of the Dog functional component.

1. const Dog = () => {
2.   let dog;
3.   useEffect(() => {
4.     dog = new Animal("Rusty", 5);
5.   }, []);
6.   return <button onClick={() => dog.greet()}>Greet</button>;
7. };
  • In line no. 2 we define a variable named dog, nice and simple.

  • From line no. 3 to 5, we call the useEffect hook and pass in a callback function to it. The callback has a closure over the variable dog that we created in line no. 1.
    Also, since we have passed [] as dependency array this means the callback will only be called once when the component is mounted.

  • Finally in line no. 6 we return a button whose onClick handler also has a closure over the same dog variable.

Dog component on mount

When the Dog component is called for the first time (on mount) following things happen:

  1. dog variable is created.
  2. useEffect hook is called and a callback is passed (note that the callback is just passed and not called).
  3. A button is returned.
  4. And finally the useEffect callback passed in step 2 is called and because it has a closure over the dog variable, the same dog variable that was created in step one get's updated to store an instance of the Animal class (i.e. new Animal("Rusty", 5)).

Greet button is clicked

Now, when the "Greet" button is clicked, it's onClick handler is called, which has closure over the same dog variable which got updated to "Rusty", therefore we get the expected alert on the screen.

Dog component on second render

When the Dog component is called again (by making state changes in the parent component) following things happen:

  1. A new dog variable is created.
  2. useEffect hook is called and a callback is passed, but note that this callback will never be called because of how the dependency array is set up.
  3. Finally a button is returned whose onClick handler now has a closure over the new dog variable.

Greet button is clicked again

Now when the "Greet" button is clicked again, the onClick handler is called, which now has a closure over the new dog variable and this time the variable is undefined, so we get an error.

So, the point to note here is that with functional component every time there's a re-render all the variables inside the function get re-created and closures over these variables also get updated.

Solution

The solution with useRef is good enough, useRef offers the following two things:

  1. It's value persists between re-renders.
  2. And it can be mutated without causing a re-render.

But in this case the solution can be made even better by moving the dog variable out of the component.

This solution works really well if you want to instantiate a library once and then call methods on that instance inside your component.

const { useState } = React;

class Animal {
  constructor(name) {
    this.name = name;
  }

  greet() {
    alert(`Hello I'm ${this.name}!`);
  }
}

const dog = new Animal("Rusty", 5);

const Dog = () => {
  return <button onClick={() => dog.greet()}>Greet</button>;
};

const App = () => {
  const [num, setNum] = useState(1);

  return (
    <main>
      <p>{num}</p>
      <button onClick={() => setNum(num + 1)}>Add 1</button>
      <br />
      <Dog />
    </main>
  );
};

ReactDOM.render(<App />, document.querySelector("#react-container"));
<div id="react-container"></div>

<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>

On a sidenote, useMemo should not be used for this, it is only meant for optimisations.

Upvotes: 15

Shota
Shota

Reputation: 7330

Update:

As it is needed to load a library, it is better to persist it in the React Context, so it can be used in other components too:

Context:

import { ReactNode, useState } from 'react';

const ApiRegister = ({ children }: { children: ReactNode }) => {
  const [api, setApi] = useState(null);

  const registerApi = api => setApi(api);

  return (
    <ApiContext.Provider
      value={{
        api,
        registerApi,
      }}
    >
      {children}
    </ApiContext.Provider>
  );
};

export default ApiRegister;

Saving API to context:

const ApiInitializer = () => {
  const dispatch = useDispatch();
  const { api, registerApi }: ContextType = useContext(ApiContext);

  useEffect(() => {
    const api = initializeApi();
    api.then(api => registerApi(api));
  }, [dispatch, registerApi]);


  return (
    <Component />
  );
};

export default ApiInitializer;

Old answer:

useMemo could be a good solution here, seems cleaner than refs:

const Dog = () => {
  const dog = useMemo(() => new Animal('Rusty', 5), []);

  return <button onClick={() => dog.greet()}>Greet</button>;
};

Upvotes: 0

Related Questions