Reputation:
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
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 mountWhen the Dog
component is called for the first time (on mount) following things happen:
dog
variable is created.useEffect
hook is called and a callback is passed (note that the callback is just passed and not called).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)
).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 renderWhen the Dog
component is called again (by making state changes in the parent component) following things happen:
dog
variable is created.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.onClick
handler now has a closure over the new dog
variable.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.
The solution with useRef
is good enough, useRef
offers the following two things:
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
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