Ensure entrance css transition with React component on render - reactjs

I am trying to build a barebones css transition wrapper in React, where a boolean property controls an HTML class that toggles css properties that are set to transition. For the use case in question, we also want the component to be unmounted (return null) before the entrance transition and after the exit transition.
To do this, I use two boolean state variables: one that controls the mounting and one that control the HTML class. When props.in goes from false to true, I set mounted to true. Now the trick: if the class is set immediate to "in" when it's first rendered, the transition does not occur. We need the component to be rendered with class "out" first and then change the class to "in".
A setTimeout works but is pretty arbitrary and not strictly tied to the React lifecycle. I've found that even a timeout of 10ms can sometimes fail to produce the effect. It's a crapshoot.
I had thought that using useEffect with mounted as the dependency would work because the component would be rendered and the effect would occur after:
useEffect(if (mounted) { () => setClass("in"); }, [mounted]);
(see full code in context below)
but this fails to produce the transition. I believe this is because React batches operations and chooses when to render to the real DOM, and most of the time doesn't do so until after the effect has also occurred.
How can I guarantee that my class value is change only after, but immediately after, the component is rendered after mounted gets set to true?
Simplified React component:
function Transition(props) {
const [inStyle, setInStyle] = useState(props.in);
const [mounted, setMounted] = useState(props.in);
function transitionAfterMount() {
// // This can work if React happens to render after mounted get set but before
// // the effect; but this is inconsistent. How to wait until after render?
setInStyle(true);
// // this works, but is arbitrary, pits UI delay against robustness, and is not
// // tied to the React lifecycle
// setTimeout(() => setInStyle(true), 35);
}
function unmountAfterTransition() {
setTimeout(() => setMounted(false), props.duration);
}
// mount on props.in, or start exit transition on !props.in
useEffect(() => {
props.in ? setMounted(true) : setInStyle(false);
}, [props.in]);
// initiate transition after mount
useEffect(() => {
if (mounted) { transitionAfterMount(); }
}, [mounted]);
// unmount after transition
useEffect(() => {
if (!props.in) { unmountAfterTransition(); }
}, [props.in]);
if (!mounted) { return false; }
return (
<div className={"transition " + inStyle ? "in" : "out"}>
{props.children}
</div>
)
}
Example styles:
.in: {
opacity: 1;
}
.out: {
opacity: 0;
}
.transition {
transition-property: opacity;
transition-duration: 1s;
}
And usage
function Main() {
const [show, setShow] = useState(false);
return (
<>
<div onClick={() => setShow(!show)}>Toggle</div>
<Transition in={show} duration={1000}>
Hello, world.
</Transition>
<div>This helps us see when the above component is unmounted</div>
</>
);
}

Found the solution looking outside of React. Using window.requestAnimationFrame allows an action to be take after the next DOM paint.
function transitionAfterMount() {
// hack: setTimeout(() => setInStyle(true), 35);
// not hack:
window.requestAnimationFrame(() => setInStyle(true));
}

Related

Why I'm getting Uncaught TypeError: Cannot read properties of null (reading 'add') on deployment?

I'm working on a editor app using fabric.js.On localhost, When i click on Add Circle it works fine while on deployment it is causing cannot read properties of null.
Here is the code:
I'm using react context api by which i add objects in canvas and it displays on screen.
FabricCircle.js
import { fabric } from 'fabric';
import ContextCanvas from '../../../context/ContextCanvas';
import { Button } from '#chakra-ui/react';
const FabricTextBox = () => {
const [canvas] = useContext(ContextCanvas);
function addTextBox() {
const textbox = new fabric.Textbox('Click on the Rectangle to move it.', {
fontSize: 20,
left: 50,
top: 100,
width: 200,
fill: 'black',
color: 'white',
cornerColor: 'blue',
});
canvas.add(textbox);
canvas.requestRenderAll();
}
return (
<>
<Button
type="button"
colorScheme="blue"
onClick={addTextBox}
variant={'ghost'}
_hover={{}}
_focus={{}}
_active={{}}
textColor={'white'}
fontWeight={'light'}
>
Text Field
</Button>
</>
);
};
export default FabricTextBox;
FabricCanvas.js
import React, { useContext, useLayoutEffect } from 'react';
import { fabric } from 'fabric';
import ContextCanvas from '../../context/ContextCanvas';
const FabricCanvas = () => {
const [canvas, initCanvas] = useContext(ContextCanvas);
useLayoutEffect(() => {
return () => {
initCanvas(new fabric.Canvas('c'));
};
}, []);
return (
<>
<canvas
id="c"
width={window.innerWidth}
height={window.innerHeight}
/>
</>
)
}
export default FabricCanvas;
ContextCanvas.js
import { fabric } from 'fabric';
const ContextCanvas = createContext();
export function CanvasProvider({ children }) {
const [canvas, setCanvas] = useState(null);
const initCanvas = c => {
setCanvas(c);
c.renderAll();
};
return (
<ContextCanvas.Provider value={[canvas, initCanvas]}>
{children}
</ContextCanvas.Provider>
);
}
export default ContextCanvas;
I think the error is related to this line in FabricCircle.js
canvas.add(textbox);
^^^^
because your canvas object is null in production.
Assuming you use React.StrictMode
Using React.StrictMode
With Strict Mode starting in React 18, whenever a component mounts in development, React will simulate immediately unmounting and remounting the component:
Strict mode flow (read more)
* React mounts the component.
* Layout effects are created.
* Effect effects are created.
* React simulates effects being destroyed on a mounted component.
* Layout effects are destroyed. [THIS]
* Effects are destroyed.
* React simulates effects being re-created on a mounted component.
* Layout effects are created
* Effect setup code runs
The step marked with [THIS] is what makes you feel all right in the local environment (but it's an illusion... See why in the next section).
With that been said:
Your canvas is initialized null inside useState and in the useLayoutEffect, you are calling the initCanvas method inside the cleanup function (so it will only be called when it's too late in production, while in development with StrictMode act like an init-function although it's a cleanup function).
useLayoutEffect(() => {
// Your code should be here
return () => { // The returned function is the cleanup function
// This is executed only when disposing the component.
initCanvas(new fabric.Canvas('c'));
// But when strict mode is active
// the component is disposed and re-mounted immidiatly.
};
}, []);
This is why the local environment works and the production environment doesn't.
Solution
Try updating your useLayoutEffect like this:
useLayoutEffect(() => {
initCanvas(new fabric.Canvas('c'));
}, []);
Conclusion
You should not initialize your state inside a cleanup function.
In this case, React.StrictMode behavior prevents you from realizing the error (without strict mode, it wouldn't even work in development).
Since you were initializing the canvas inside the cleanup function, the canvas never get initialized in time (without the strict mode), remaining null, as the error you receive states.

How to set window resize event listener value to React State?

This issue is very simple but I probably overlook very little point. Window screen size is listening by PostLayout component. When window width is less than 768px, I expect that isDesktopSize is false. I tried everything like using arrow function in setIsDesktopSize, using text inside of true or false for state value, using callback method etc... but it's not working.
PostLayout shared below:
import React, {useState,useEffect, useCallback} from 'react'
import LeftSideNavbar from './LeftSideNavbar'
import TopNavbar from './TopNavbar'
export default function PostLayout({children}) {
const [isDesktopSize, setIsDesktopSize] = useState(true)
let autoResize = () => {
console.log("Desktop: " + isDesktopSize);
console.log(window.innerWidth);
if(window.innerWidth < 768 ){
setIsDesktopSize(false)
}else{
setIsDesktopSize(true)
}
}
useEffect(() => {
window.addEventListener('resize', autoResize)
autoResize();
}, [])
return (
<>
<TopNavbar isDesktopSize={isDesktopSize}/>
<main>
<LeftSideNavbar/>
{children}
</main>
</>
)
}
console log is shared below:
Desktop: true
627
This could probably be extracted into a custom hook. There's a few things you'd want to address:
Right now you default the state to true, but when the component loads, that may not be correct. This is probably why you see an incorrect console log on the first execution of the effect. Calculating the initial state to be accurate could save you some jank/double rendering.
You aren't disconnecting the resize listener when the component unmounts, which could result in an error attempting to set state on the component after it has unmounted.
Here's an example of a custom hook that addresses those:
function testIsDesktop() {
if (typeof window === 'undefined') {
return true;
}
return window.innerWidth >= 768;
}
function useIsDesktopSize() {
// Initialize the desktop size to an accurate value on initial state set
const [isDesktopSize, setIsDesktopSize] = useState(testIsDesktop);
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
function autoResize() {
setIsDesktopSize(testIsDesktop());
}
window.addEventListener('resize', autoResize);
// This is likely unnecessary, as the initial state should capture
// the size, however if a resize occurs between initial state set by
// React and before the event listener is attached, this
// will just make sure it captures that.
autoResize();
// Return a function to disconnect the event listener
return () => window.removeEventListener('resize', autoResize);
}, [])
return isDesktopSize;
}
Then to use this, your other component would look like this (assuming your custom hook is just in this same file -- though it may be useful to extract it to a separate file and import it):
import React, { useState } from 'react'
import LeftSideNavbar from './LeftSideNavbar'
import TopNavbar from './TopNavbar'
export default function PostLayout({children}) {
const isDesktopSize = useIsDesktopSize();
return (
<>
<TopNavbar isDesktopSize={isDesktopSize}/>
<main>
<LeftSideNavbar/>
{children}
</main>
</>
)
}
EDIT: I modified this slightly so it should theoretically work with a server-side renderer, which will assume a desktop size.
Try this, you are setting isDesktopSizze to 'mobile', which is === true
const [isDesktopSize, setIsDesktopSize] = useState(true)
let autoResize = () => {
console.log("Desktop: " + isDesktopSize);
console.log(window.innerWidth);
if(window.innerWidth < 768 ){
setIsDesktopSize(true)
}else{
setIsDesktopSize(false)
}
}
I didn't find such a package on npm and I thought it would be nice to create one: https://www.npmjs.com/package/use-device-detect. I think it will help someone :)

How to detect window size in Next.js SSR using react hook?

I am building an app using Next.js and react-dates.
I have two component DateRangePicker component and DayPickerRangeController component.
I want to render DateRangePicker when the window's width is bigger than size 1180px, if the size is smaller than this I want to render DayPickerRangeController instead.
Here is the code:
windowSize > 1180 ?
<DateRangePicker
startDatePlaceholderText="Start"
startDate={startDate}
startDateId="startDate"
onDatesChange={handleOnDateChange}
endDate={endDate}
endDateId="endDate"
focusedInput={focus}
transitionDuration={0}
onFocusChange={(focusedInput) => {
if (!focusedInput) {
setFocus("startDate")
} else {
setFocus(focusedInput)
}
}}
/> :
<DayPickerRangeController
isOutsideRange={day => isInclusivelyBeforeDay(day, moment().add(-1, 'days'))}
startDate={startDate}
onDatesChange={handleOnDateChange}
endDate={endDate}
focusedInput={focus}
onFocusChange={(focusedInput) => {
if (!focusedInput) {
setFocus("startDate")
} else {
setFocus(focusedInput)
}
}}
/>
}
I normally use react hook with window object to detect window screen width like this
But I found that this way is not available when ssr because ssr rendering does not have window object.
Is there an alternative way I can get window size safely regardless of ssr?
You can avoid calling your detection function in ssr by adding this code:
// make sure your function is being called in client side only
if (typeof window !== 'undefined') {
// detect window screen width function
}
full example from your link:
import { useState, useEffect } from 'react';
// Usage
function App() {
const size = useWindowSize();
return (
<div>
{size.width}px / {size.height}px
</div>
);
}
// Hook
function useWindowSize() {
// Initialize state with undefined width/height so server and client renders match
// Learn more here: https://joshwcomeau.com/react/the-perils-of-rehydration/
const [windowSize, setWindowSize] = useState({
width: undefined,
height: undefined,
});
useEffect(() => {
// only execute all the code below in client side
// Handler to call on window resize
function handleResize() {
// Set window width/height to state
setWindowSize({
width: window.innerWidth,
height: window.innerHeight,
});
}
// Add event listener
window.addEventListener("resize", handleResize);
// Call handler right away so state gets updated with initial window size
handleResize();
// Remove event listener on cleanup
return () => window.removeEventListener("resize", handleResize);
}, []); // Empty array ensures that effect is only run on mount
return windowSize;
}
NB: Updated as Sergey Dubovik comment, we dont need to validate windows since useEffect run in client side
While Darryl RN has provided an absolutely correct answer. I'd like to make a small remark: You don't really need to check for the existence of the window object inside useEffect because useEffect only runs client-side and never server-side, and the window object is always available on the client-side.
useEffect(()=> {
window.addEventListener('resize', ()=> {
console.log(window.innerHeight, window.innerWidth)
})
}, [])
here's the solution i'm using: a small npm package found here use-window-size
once installed and imported, all you need to do is use it like this:
const { innerWidth, innerHeight, outerHeight, outerWidth } = useWindowSize();
return (
<div>Window width: {innerWidth}</div>
)

Delay state change with async/await?

I'm making a custom error window that pops up in various situations. What I'm struggling with is getting the window to dissapear after 2 seconds.. Just a simple setTimeout to change the popup state to active:false is a bit unreliable because of the way the even loop works (i think?).
So I'm attempting an async/await way of doing it making sure it's always exactly 2 seconds. However the way I have done it below the timing still seems to be very weird, sometimes instant, sometimes 2 seconds.
How do I get my removeErrorMsg function to wait 2 seconds before setting the state?
///// App.js.js ////
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
export default class App extends Component {
state = {
errorPopup: {
active: false,
message: ''
}
}
removeErrorMsg = async() => {
await delay(2000);
this.setState({errorPopup: {active: false, message: ''}});
}
}
///// ErrorPopup.js ////
import React from 'react'
const ErrorPopup = ({ message, active, removeErrorMsg}) => {
if(active){
removeErrorMsg()
return (
<div className="error-popup">
<p>{message}</p>
</div>
)
} else return <div></div>
}
export default ErrorPopup
You must call the removeErrorMsg inside the ErrorPopup component within a useEffect function. Directly calling it will result in another delay being created which resets the state as soon as any other action in parent component tries to trigger a re-render leading to unexpected behaviours
const ErrorPopup = ({ message, active, removeErrorMsg}) => {
useEffect(() => {
if(active) {
removeErrorMsg()
}
}, [active])
if(active){
return (
<div className="error-popup">
<p>{message}</p>
</div>
)
} else return <div></div>
}
P.S. Although there is no gurantee that the setTimeout will execute immediately at 2sec, more or less it roughly execute around 2sec.

React page / layout transitions with useLayoutEffect

I have following structure defined in order to animate transitions between layouts based on pathname.
<LayoutTransition pathname={pathname}>
{pathname.includes('/registration') && <RegistrationLayout />}
{pathname.includes('/dashboard') && <DashboardLayout />}
</LayoutTransition>
RegistrationLayout and DashboardLayout have similar structure inside, but they display different pages based on pathname as opposed to layouts.
Inside my LayoutTransition component I have following logic
useLayoutEffect(() => {
// Root paths are "/registration" "/dashboard"
const rootPathname = pathname.split('/')[1];
const rootPrevPathname = prevPathname.current.split('/')[1];
if (rootPathname !== rootPrevPathname) {
/*
* Logic that:
* 1. Animates component to 0 opacity
* 2. Sets activeChildren to null
* 3. Animates component to 1 opacity
*/
}
return () => {
// Sets activeChildren to current one
setActiveChildren(children);
};
}, [pathname]);
/* renders activeChildren || children */
In general this concept works i.e I see my "current" children while animating out then as activeChildren are set to null, I see my "new" children when animating in.
Only issue is that it seems as if when I set setActiveChildren(children); layout re-renders, I see flicker and page that layout was displaying returns to its initial state.
Is there a way to avoid this and sort of freeze children when we are animating out, so no re-render on them happens?
EDIT: Full code snippet from react-native project
Core idea is that we subscribe to router context, when rootPathname changes we animate current layout (children) out and then animate new ones in.
import React, { useContext, useLayoutEffect, useRef, useState } from 'react';
import { Animated } from 'react-native';
import RouterCtx from '../context/RouterCtx';
import useAnimated from '../hooks/useAnimated';
import { durationSlow, easeInQuad } from '../services/Animation';
/**
* Types
*/
interface IProps {
children: React.ReactNode;
}
/**
* Component
*/
function AnimRouteLayout({ children }: IProps) {
const { routerState } = useContext(RouterCtx);
const { rootPathname } = routerState;
const [activeChildren, setActiveChildren] = useState<React.ReactNode>(undefined);
const [pointerEvents, setPointerEvents] = useState(true);
const prevRootPathname = useRef<string | undefined>(undefined);
const [animatedValue, startAnimation] = useAnimated(1, {
duration: durationSlow,
easing: easeInQuad,
useNativeDriver: true
});
function animationLogic(finished: boolean, value: number) {
setPointerEvents(false);
if (finished) {
if (value === 0) {
setActiveChildren(undefined);
startAnimation(1, animationLogic, { delay: 150 });
}
setPointerEvents(true);
}
}
useLayoutEffect(() => {
if (prevRootPathname.current) {
if (rootPathname !== prevRootPathname.current) {
startAnimation(0, animationLogic);
}
}
return () => {
prevRootPathname.current = rootPathname;
setActiveChildren(children);
};
}, [rootPathname]);
return (
<Animated.View
pointerEvents={pointerEvents ? 'auto' : 'none'}
style={{
flex: 1,
opacity: animatedValue.interpolate({ inputRange: [0, 1], outputRange: [0, 1] }),
transform: [
{
scale: animatedValue.interpolate({ inputRange: [0, 1], outputRange: [1.1, 1] })
}
]
}}
>
{activeChildren || children}
</Animated.View>
);
}
export default AnimRouteLayout;
First I'll describe what I believe is happening with your current code by laying out the steps that would occur when a user starts at "/registration" and then switches to "/dashboard":
Initial render of AnimRouteLayout with rootPathname='/registration'
initial layout effect is queued
activeChildren is undefined, so children is returned to be rendered
<RegistrationLayout /> is rendered
the queued layout effect executes
prevRootPathname.current is undefined, so no animation
layout effect cleanup is registered with React
User switches to "/dashboard" triggering render of AnimRouteLayout with rootPathname='/dashboard'
since rootPathname is different, a second layout effect is queued
activeChildren is still undefined, so children is returned to be rendered
<RegistrationLayout /> unmounts and <DashboardLayout /> is rendered
the cleanup for the previous layout effect executes
prevRootPathname.current gets set to '/registration'
activeChildren gets set to the previous children causing another render to be queued
the queued layout effect executes and starts the animation
another render of AnimRouteLayout begins due to the activeChildren state change
an additional layout effect is not queued because rootPathname is not different
activeChildren is returned to be rendered
<DashboardLayout /> unmounts
<RegistrationLayout /> remounts with fresh state and is rendered
animation completes and sets activeChildren back to undefined
AnimRouteLayout renders again and this time <DashboardLayout /> will be rendered
Though it would be possible to manage activeChildren in a manner that prevents the re-mounting, I think there is a cleaner way to approach this problem. Rather than trying to freeze the children, I think you would be better off to freeze the pathname. I did a fair amount of experimentation with these ideas when writing this answer. The terminology I came up with to keep this straight is to distinguish between:
targetPathname The path the user has indicated they want to be on
renderPathname The path currently being rendered
Most of the time these paths will be the same. The exception is during the exit transition when renderPathname will have the value of the previous targetPathname. With this approach you would have something like the following:
<AnimRouteLayout targetPathname={pathname}>
{(renderPathname)=> {
return <>
{renderPathname.includes('/registration') && <RegistrationLayout />}
{renderPathname.includes('/dashboard') && <DashboardLayout />}
</>;
}}
</AnimRouteLayout>
and then AnimRouteLayout just needs to manage renderPathname appropriately:
function AnimRouteLayout({ children, targetPathname }) {
const [renderPathname, setRenderPathname] = useState(targetPathname);
// End of animation would set renderPathname to targetPathname
return children(renderPathname);
}
Since I didn't try to make a working example of this, I make no guarantees that I don't have a syntax accident in the above, but I'm fairly confident the approach is sound.

Resources