I'm trying to implement some single input form with easy "expand" animation to when going to/from edit mode.
Basically I created a ghost element containing value, next to this element is icon button working as edit/save. When you click on the edit button, input with value should appear instead of ghost element and width of input should expand/decrease to constant defined.
I have so far this piece of code, which mostly works fine, but for expand it sometimes doesn't animate and I don't know why.
toggleEditMode = () => {
const { editMode } = this.state
if (editMode) {
this.setState(
{
inputWidth: this.ghostRef.current.clientWidth
},
() => {
requestAnimationFrame(() => {
setTimeout(() => {
this.setState({
editMode: false
})
}, 150)
})
}
)
} else {
this.setState(
{
editMode: true,
inputWidth: this.ghostRef.current.clientWidth
},
() => {
requestAnimationFrame(() => {
this.setState({
inputWidth: INPUT_WIDTH
})
})
}
)
}
}
You can take a look on example here. Could someone explain what's wrong or help me to find solution? If I'll add another setTimeout(() => {...expand requestAnimationFrame here...}, 0) in code, it starts to work, but I don't like the code at all.
This answer explains what's going on in detail and how to fix it. However, I wouldn't actually suggest implementing it.
Custom animations are messy, and there are great libraries that handle the dirty work for you. They wrap the refs and requestAnimationFrame code and give you a declarative API instead. I have used react-spring in the past and it has worked very well for me, but Framer Motion looks good as well.
However, if you'd like to understand what's happening in your example, read on.
What's happening
requestAnimationFrame is a way to tell the browser to run some code every time a frame is rendered. One of the guarantees you get with requestAnimationFrame is that the browser will always wait for your code to complete before the browser renders the next frame, even if this means dropping some frames.
So why doesn't this seem to work like it should?
Updates triggered by setState are asynchronous. React doesn't guarantee a re-render when setState is called; setState is merely a request for a re-evaluation of the virtual DOM tree, which React performs asynchronously. This means that setState can and usually does complete without immediately changing the DOM, and that the actual DOM update may not occur until after the browser renders the next frame.
This also allows React to bundle multiple setState calls into one re-render, which it sometimes does, so the DOM may not update until the animation is complete.
If you want to guarantee a DOM change in requestAnimationFrame, you'll have to perform it yourself using a React ref:
const App = () => {
const divRef = useRef(null);
const callbackKeyRef = useRef(-1);
// State variable, can be updated using setTarget()
const [target, setTarget] = useState(100);
const valueRef = useRef(target);
// This code is run every time the component is rendered.
useEffect(() => {
cancelAnimationFrame(callbackKeyRef.current);
const update = () => {
// Higher is faster
const speed = 0.15;
// Exponential easing
valueRef.current
+= (target - valueRef.current) * speed;
// Update the div in the DOM
divRef.current.style.width = `${valueRef.current}px`;
// Update the callback key
callbackKeyRef.current = requestAnimationFrame(update);
};
// Start the animation loop
update();
});
return (
<div className="box">
<div
className="expand"
ref={divRef}
onClick={() => setTarget(target === 100 ? 260 : 100)}
>
{target === 100 ? "Click to expand" : "Click to collapse"}
</div>
</div>
);
};
Here's a working example.
This code uses hooks, but the same concept works with classes; just replace useEffect with componentDidUpdate, useState with component state, and useRef with React.createRef.
It seems to be a better direction to use CSSTransition from react-transition-group, in your component:
function Example() {
const [tr, setIn] = useState(false);
return (
<div>
<CSSTransition in={tr} classNames="x" timeout={500}>
<input
className="x"
onBlur={() => setIn(false)}
onFocus={() => setIn(true)}
/>
</CSSTransition>
</div>
);
}
and in your css module:
.x {
transition: all 500ms;
width: 100px;
}
.x-enter,
.x-enter-done {
width: 400px;
}
It lets you avoid using setTimeouts and requestAnimationFrame and would make the code cleaner.
Codesandbox: https://codesandbox.io/s/csstransition-component-forked-3o4x3?file=/index.js
Related
codesandbox.io sandbox
github.com repository
I am creating this small Wiki project.
My main component is Editor(), which has handleClick() and handleModeChange() functions defined.
handleClick() fires when a page in the left sidebar is clicked/changed.
handleModeChange() switches between read and write mode (the two icon buttons in the left sidebar).
When in read mode, the clicking on different pages in the left sidebar works properly and changes the main content on the right side.
However, in write mode, when the content is echoed inside <TextareaAutosize>, the content is not changed when clicking in the left menu.
In Content.js, I have:
<TextareaAutosize
name="textarea"
value={textareaValue}
minRows={3}
onChange={handleMarkdownChange}
/>
textareaValue is defined in Content.js as:
const [textareaValue, setTextareaValue] = useState(props.currentMarkdown);
const handleMarkdownChange = e => {
setTextareaValue(e.target.value);
props.handleMarkdownChange(e.target.value);
};
I am unsure what is the correct way to handle this change inside textarea. Should I somehow force Editor's child, Content, to re-render with handleClick(), or am I doing something completely wrong and would this issue be resolved if I just changed the definition of some variable?
I have been at this for a while now...
You just need to update textareaValue whenever props.currentMarkdown changes. This can be done using useEffect.
useEffect(() => {
setTextareaValue(props.currentMarkdown);
}, [props.currentMarkdown]);
Problem:
const [textareaValue, setTextareaValue] = useState(props.currentMarkdown);
useState will use props.currentMarkdown as the initial value but it doesn't update the current state when props.currentMarkdown changes.
Unrelated:
The debounce update of the parent state can be improved by using useRef
const timeout = useRef();
const handleMarkdownChange = (newValue) => {
if (timeout.current) {
clearTimeout(timeout.current);
}
timeout.current = setTimeout(function () {
console.log("fire");
const items = [];
for (let value of currentData.items) {
if (value["id"] === currentData.active) {
value.markdown = newValue;
value.unsaved = true;
}
items.push(value);
}
setCurrentData({ ...currentData, items: items });
}, 200);
};
using var timeout is bad because it declares timeout on every render, whereas useRef gives us a mutable reference that is persisted across renders
I'm just playing around with ReactJS and trying to figure out some strange behavior with the useState hook.
A component should not re-rendered if the state is set with the same primitive value (Boolean) as it was before
const useScroll = ({positionToCross = 10}) => {
const window = useWindow();
const [isPositionCrossed, setIsPositionCrossed] = useState(window.scrollY > positionToCross);
useEffect(() => {
const onScroll = function (e) {
window.requestAnimationFrame(function () {
const lastKnownScrollPosition = window.scrollY;
setIsPositionCrossed(lastKnownScrollPosition > positionToCross);
});
}
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener("scroll", onScroll)
}
}, []);
console.log(`useScroll - render window.scrollY = ${window.scrollY.toFixed(0)} isPositionCrossed = `, isPositionCrossed)
return {isPositionCrossed}
}
here is the console output - you can see the component and the hook are both rendered two times with "true" (after scrolled over 100px)
"useScroll - render window.scrollY = 101 isPositionCrossed = ", true
"useScroll - render window.scrollY = 103 isPositionCrossed = ", true
If you try simple code that on click handler setState and if you click two times and in each update state with same value the component again re-render.
As react doc says:
If you update a State Hook to the same value as the current state, React will bail out without rendering the children or firing effects. (React uses the Object.is comparison algorithm.)
Note that React may still need to render that specific component again before bailing out. That shouldn’t be a concern because React won’t unnecessarily go “deeper” into the tree. If you’re doing expensive calculations while rendering, you can optimize them with useMemo.
I hope the answers from this post and this github discussion help you to understand why this happens
and there are another related topics like this post and this one
The goal of my hook is to determine the "open direction" of the menu.
When there is not enough height, the open direction will switch from "bottom" to "top".
I simplified my code to a dummy example to be able to demonstrate it.
You can see it in the animated gif I added.
So, what is my problem?
In my real code, the logic of the "open direction" depends on the last "openDirection" from the state.
With classes which I'm used to, it works well, I've added the code example using classes.
In the handleResize I'm writing to the console the current openDirection, to simulate the use of it in my real code.
BUT in my hooks code example, you can see that each time I am writing to the console from the handleResize, the openDirection is always the first initial value of the state which is bottom.
I guess it works this way because I'm giving the [] dependency, so useLayoutEffect works on the first time only. But what should I do? if I pass openDirection as a dependency, it will work, but then it means that each time addEventListener will be added and removeEventListener will be removed, for every resize event!
I'm really confused about it, what should I do?
import { useLayoutEffect, useState } from 'react';
const useOpenDirection = () => {
const [openDirection, setOpenDirection] = useState("bottom");
useLayoutEffect(() => {
const handleResize = () => {
console.log('openDirection', openDirection);
if (window.innerHeight < 622) {
setOpenDirection("top");
} else {
setOpenDirection("bottom");
}
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return openDirection;
};
export default useOpenDirection;
The complete code for live editing:
classes code example
hooks code example
Your openDirection has become a stale closure, because of empty array as dependencies. You can access current value of openDirection if you use functional update:
useLayoutEffect(() => {
const handleResize = () => {
setOpenDirection(current => {
// If you need you can also use current to compute return value of this function
if (window.innerHeight < 622) {
console.log('openDirection', current);
return "top";
} else {
console.log('openDirection', current);
return "bottom";
}
});
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
I have a functional component that keeps a state. This state I try to manipulate using an onClick event in an SVG. The SVG is in another component and has the addAndRemoveSelectedCabin method passed to it via props. I loop through the elements in an useEffect and add an eventListener. This doesn't work. The useEffect with the selectedCabins dependency logs the new number only. It seems the state returns to the initial state after every stateChange.
This is the state and method in the parent component.
const [selectedCabins, setSelectedCabins] = useState([0]);
const addRemoveSelectedCabin = id => {
const newArr = [...selectedCabins, id];
setSelectedCabins(newArr);
}
useEffect(() => {
console.log(selectedCabins);
}, [selectedCabins])
This is how I call the method. [UPDATE]
useEffect(() =>
{
const cabins = document.querySelectorAll(".cabin");
cabins.forEach(cabin =>
{
const id = cabin.getAttributeNS(null, "id").substring(1, 5);
const found = cabinsData.find(el => el.id === id)
if (found && found.status === "available")
{
cabin.classList.add("green")
cabin.addEventListener('click', () => addRemoveSelectedCabin(id));
} else if (found && found.status === "booked")
{
cabin.classList.add("gray")
}
})
}, [])
Console:
[0]
(2) [0, "1105"]
(2) [0, "1101"]
This works if I put the onClick directly in the SVG element. Does anyone know why this is?
<rect
id="C1105"
x="749.4"
y="58.3"
className="cabin"
width="36.4"
height="19.9"
onClick={() => addRemoveSelectedCabin(1105)}
>
<title>1105</title>
</rect>
As I said in my comment, you are binding addRemoveSelectedCabin in the first render. useEffect is only executed once since you pass an empty dependency list. addRemoveSelectedCabin closes over selectedCabins which at that point in time has the value [0].
Why am I seeing stale props or state inside my function? from the React documentation has more information about this.
The solution in your case is simple: Pass a function to the setter to get the "current" state value. Don't reference the state value in the component:
const addRemoveSelectedCabin = id => {
setSelectedCabins(selectedCabins => [...selectedCabins, id]);
}
Having said that, this is still an odd thing to do in React world. You should reevaluate your assumptions that make you think you have to do it that way.
It's not all the elements that should have a click listener.
Depending on how you actually render the elements, that's easy to do. JSX/React is just JavaScript. Whether you have a condition that adds the event handler or not or whether you have a condition that sets onClick or not is basically the same.
But without a more complete example there is not much we can suggest.
So I've got this hook to return the windowWidth for my App components. I'll call this Option #1.
import {useEffect, useState} from 'react';
function useWindowWidth() {
const [windowWidth,setWindowWidth] = useState(window.innerWidth);
useEffect(() => {
function handleResize() {
setWindowWidth(window.innerWidth);
}
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
return windowWidth;
}
export default useWindowWidth;
And right now I'm basically using it on every component that depends on the window width to render, like:
function Component(props) {
const windowWidth = useWindowWidth();
return(
// RETURN SOMETHING BASED ON WINDOW WIDTH
);
}
And since the hook has an event listener for the resize events, the component stays responsive even after window resizes.
But I'm worried that I'm attaching a new listener for every component that uses that hook and it might slow things down at some point. And I've though of other approach:
Option #2
I use the useWindowWidth() hook only one time, inside a top level component like <App/> and I'll provide the windowWidth value down the chain via context.
Like:
function App() {
const windowWidth = useWindowWidth();
return(
<WindowWidthContext.Provider value={windowWidth}>
<Rest_of_the_app/>
</WindowWidthContext.Provider>
);
}
And then, every component that needs it could get it via:
function Component() {
const windowWidth = useContext(WindowWidthContext);
return(
// SOMETHING BASED ON WINDOW WIDTH
);
}
QUESTION
Am I right in being bothered by that fact that I'm setting up multiple resize listeners with Option #1 ? Is Option #2 a good way to optmize that flow?
If your window with is used by so many components as you mentioned, you must prefer using context. As it reads below:
Context is for global scope of application.
So, #2 is perfect choice here per react.
First approach #1 might be good for components in same hierarchy but only up-to 2-3 levels.
I'm not sure if adding and removing event listeners is a more expensive operation than setting and deleting map keys but maybe the following would optimize it:
const changeTracker = (debounceTime => {
const listeners = new Map();
const add = fn => {
listeners.set(fn, fn);
return () => listeners.delete(fn);
};
let debounceTimeout;
window.addEventListener('resize', () => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(
() => {
const width=window.innerWidth;
listeners.forEach(l => l(width))
},
debounceTime
);
});
return add;
})(200);
function useWindowWidth() {
const [windowWidth, setWindowWidth] = useState(
() => window.innerWidth
);
useEffect(
() =>//changeTracker returns a remove function
changeTracker((width) =>
setWindowWidth(width)
),
[]
);
return windowWidth;
}
As HMR said in an above thread, my solution was to use redux to hold the width value. With this strategy you only need one listener and you can restrict how often you update with whatever tool you like. You could check if the width value is within the range of a new breakpoint and only update redux when that is true. This only works if your components dont need a steady stream of the window width, in that case just debounce.