Reputation:
I tried to change a class based component into a functional component and run into troubles when I tried to read state properties inside a handler function attached with useEffect()
Inside the class based component I'll attach the handler method inside componentDidMount
and this handler has access to the state.
The rendered output does show the correct values in both types of components!
But when I need the current value of the state property inside my handler to calculate the new state values I run into troubles reading the state properties inside functional components.
I created following example to demonstrate the problem: https://codesandbox.io/s/peaceful-wozniak-l6w2c (open console and scroll)
If you click on the components both work fine:
counter
counter
If you scroll only the class based component works:
position
position
But the console output from the functional components only returns me the initial state property value.
class based component
import React, { Component } from "react";
export default class CCTest extends Component {
constructor(props) {
super(props);
this.state = { position: 0, counter: 0 };
this.handleScroll = this.handleScroll.bind(this);
this.handleClick = this.handleClick.bind(this);
}
handleScroll(e) {
console.log(
"class.handleScroll()",
this.state.position
);
this.setState({ position: document.body.getBoundingClientRect().top });
}
handleClick() {
console.log("[class.handleClick]", this.state.counter);
this.setState(prevState => ({ counter: prevState.counter + 1 }));
}
componentDidMount() {
window.addEventListener("scroll", this.handleScroll);
}
render() {
return (
<div
style={{
backgroundColor: "orange",
padding: "20px",
cursor: "pointer"
}}
onClick={this.handleClick}
>
<strong>class</strong>
<p>
position: {this.state.position}, counter: {this.state.counter}
</p>
</div>
);
}
}
functional component
import React from "react";
export default prop => {
const [position, setPosition] = React.useState(0);
const [counter, setCounter] = React.useState(0);
React.useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
// eslint-disable-next-line
}, []);
const handleScroll = () => {
console.log(
"function.handleScroll()",
position
);
setPosition(document.body.getBoundingClientRect().top);
};
const handleClick = () => {
console.log("[function.handleClick]", counter);
setCounter(counter + 1);
};
return (
<div
style={{ backgroundColor: "green", padding: "20px", cursor: "pointer" }}
onClick={handleClick}
>
<strong>function</strong>
<p>
position: {position}, counter: {counter}
</p>
</div>
);
};
Every little hint helps <3
Upvotes: 1
Views: 188
Reputation:
Thanks to the help of @Anxo I ended up ditching useState
and utilize useRecuder
instead.
This is my functional component for the specific example in the initial question:
import React from 'react';
const reducer = (state, action) => {
switch (action.type) {
case 'UPDATE_POSITION':
return {
...state,
position: document.body.getBoundingClientRect().top
};
case 'INCREMENT_COUNTER':
return {
...state,
counter: state.counter + 1
};
default:
throw new Error('chalupa batman');
}
};
const initialState = {
position: 0,
counter: 0
};
const FCTest = props => {
const [state, dispatch] = React.useReducer(reducer, initialState);
React.useEffect(() => {
window.addEventListener('scroll', () => {
dispatch({ type: 'UPDATE_POSITION' });
});
return () => {
window.removeEventListener(
'scroll',
dispatch({ type: 'UPDATE_POSITION' })
);
};
// eslint-disable-next-line
}, []);
return (
<div
style={{ backgroundColor: 'green', padding: '20px', cursor: 'pointer' }}
onClick={() => dispatch({ type: 'INCREMENT_COUNTER' })}
>
<strong>function</strong>
<p>
position: {state.position}, counter: {state.counter}
</p>
</div>
);
};
export default FCTest;
Upvotes: 1
Reputation: 475
Every time your component renders, a new handleScroll function is created, which has access through the component closure to the current state. But this closure doesn't get updated on every render; instead, a new handleScroll function is created, which sees the new values.
The problem is that when you do:
window.addEventListener("scroll", handleScroll);
You're binding the current version of the function to the event, and it will always read the values at that time, instead of new ones. Normally, useEffect would be run again and a new instance of the function would be used, but you're preventing it with the empty array as the second parameter.
There is a linter warning just for these cases, but you have disabled it:
// eslint-disable-next-line
If you remove this empty array, you can remove the linter disabling rule, and it will work properly.
Other options
When having the function change every time is not an option (for example, when using non-react libraries, or for performance reasons), there are other alternatives:
You can use a ref instead of state. Refs get mutated instead of a new one being created on every render. This allows previous versions of the function to read the current value, and modify it, even if you don't update the function.
You can use useReducer. The dispatch function never changes, so if your useEffect depends on it, it won't be re-run. The reducer can access both the previous value and the action, so it can calc the new state even if it depends on the previous one.
Upvotes: 4