React page / layout transitions with useLayoutEffect - reactjs

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.

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.

React fader component fade direction not changing

I'm trying to create a small component that will fade between it's children when one of its methods is called. I've been following this code, but so it supports any number of children. So far, I have this:
export const Fader = React.forwardRef<FaderProps, Props>((props, ref) => {
const children = React.Children.toArray(props.children);
const [currentChild, setCurrentChild] = useState({
child: props.startIndex || 0,
direction: 1,
});
let nextChild = 0;
const fadeNext = (): void => {
queueNextFade(); //Queues the next fade which fades in the next child after the current child has faded out
setCurrentChild({
child: currentChild.child,
direction: +!currentChild.direction,
});
nextChild = currentChild.child + 1;
}
const fadePrev = (): void => {
}
const fadeTo = (index: number): void => {
}
const queueNextFade = (): void => {
setTimeout(() => {
setCurrentChild({
child: nextChild,
direction: +!currentChild.direction,
});
}, props.fadeTime || 500)
}
useImperativeHandle(ref, () => ({ fadeNext, fadePrev, fadeTo }));
return (
<div>
{
React.Children.map(children, (child, i) => (
<div key={i}
style={{
opacity: i === currentChild.child ? currentChild.direction : "0",
transition: `opacity ${props.fadeTime || 500}ms ease-in`,
}}
>
{child}
</div>
))
}
</div>
)
});
Logic wise it does work, but what actually happens is that the first child fades out, but the next child doesn't fade in. If faded again, the second child fades in then fades out, and the next child fades in.
(View in sandbox)
For a while I was confused as to why that was happening because I'm using the same logic as the other library. I did some research on seeing if I could make useState be instant and I came across this post and I quote from it:
Even if you add a setTimeout the function, though the timeout will run after some time by which the re-render would have happened, the setTimeout will still use the value from its previous closure and not the updated one.
which I realised is what's happening in my situation. I start the setTimeout in which currentChild.direction is 1. Then a state change happens and the direction changes to 0. A long time later the setTimeout finishes but instead of changing the direction from 0 to 1, it changes from 1 to 0 because it kept it original value from when it was first called, hence why the second child doesn't fade in and just stays invisible.
I could just change it to:
let currentChild = {...}
and the have a "blank" useState to act as a forceUpdate but I know force updates are against React's nature and there's probably a better way to do this anyway.
If anyone could help out, I would appreaciate it
For anyone in the future, I found this post explaning that setTimeout will use the value from the initial render of the component
To fix this, I changed my setTimeout to:
setTimeout(() => {
setCurrentChild(currentChild => ({
child: nextChild,
direction: +!currentChild.direction,
}));
}, props.fadeTime || 500)

Custom page / route transitions in Next.js

I'm trying to achieve callback-based route transitions using Next.js's framework and Greensock animation library (if applicable). For example when I start on the homepage and then navigate to /about, I want to be able to do something like:
HomepageComponent.transitionOut(() => router.push('/about'))
ideally by listening to the router like a sort of middleware or something before pushing state
Router.events.on('push', (newUrl) => { currentPage.transitionOut().then(() => router.push(newUrl)) });
Main Problem
The main problem is that I also have a WebGL app running in the background, decoupled from the React ecosystem (since it uses requestAnimationFrame). So the reason I want callback-based transitions is because I need to run them after the WebGL transitions are done.
Current Implementation
I've looked into using React Transition Group and I've seen the docs for the Router object but neither seems to be callback-based. In other words, when I transition to a new page, the WebGL and the page transitions run at the same time. And I don't want to do a hacky solution like adding a delay for the page transitions so they happen after the WebGL ones.
This is what I have right now:
app.js
<TransitionGroup>
<Transition
timeout={{ enter: 2000, exit: 2000 }}
// unmountOnExit={true}
onEnter={(node) => {
gsap.fromTo(node, { opacity: 0 }, { opacity: 1, duration: 1 });
}}
onExit={(node) => {
gsap.to(node, { opacity: 0, duration: 1 });
}}
key={router.route}
>
<Component {...pageProps}></Component>
</Transition>
</TransitionGroup>
webgl portion
Router.events.on('routeChangeStart', (url) => {
// transition webGL elements
// ideally would transition webGL elements and then allow callback to transition out html elements
});
I've also tried using the eventemitter3 library to do something like:
// a tag element click handler
onClick(e, href) {
e.preventDefault();
this.transitionOut().then(() => { Emitter.emit('push', href); });
// then we listen to Emitter 'push' event and that's when we Router.push(href)
}
However this method ran into huge issues when using the back / forward buttons for navigating
Bit late on this but I was looking into this myself today. It's really easy to use Framer Motion for this but I also wanted to use GSAP / React Transition Group.
For Framer Motion I just wrapped the Next < Component > with a motion component:
<motion.div
key={router.asPath}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<Component {...pageProps} />
</motion.div>
For GSAP / React Transition Group, not sure if this is the right way but it's working as intended for me (see comments):
const [state, setstate] = useState(router.asPath) // I set the current asPath as the state
useEffect(() => {
const handleStart = () => {
setstate(router.asPath) // then on a router change, I'm setting the state again
// other handleStart logic goes here
}
const handleStop = () => {
... // handleStop logic goes here
}
router.events.on("routeChangeStart", handleStart)
router.events.on("routeChangeComplete", handleStop)
router.events.on("routeChangeError", handleStop)
return () => {
router.events.off("routeChangeStart", handleStart)
router.events.off("routeChangeComplete", handleStop)
router.events.off("routeChangeError", handleStop)
}
}, [router])
<Transition
in={router.asPath !== state} // here I'm just checking if the state has changed, then triggering the animations
onEnter={enter => gsap.set(enter, { opacity: 0 })}
onEntered={entered => gsap.to(entered, { opacity: 1, duration: 0.3 })}
onExit={exit => gsap.to(exit, { opacity: 0, duration: 0.3 })}
timeout={300}
appear
>
<Component {...pageProps} />
</Transition>
First I recommend reading Greensock’s React documentation.
Intro Animations in Next.JS
For intro animations, if you use useLayoutEffect with SSR your console will fill up with warnings. To avoid this apply useIsomorphicLayoutEffect instead. Go to useIsomorphicLayoutEffect.
To prevent the flash of unstyled content (FOUC) with SSR, you need to set the initial styling state of the component. For example, if we are fading in, the initial style of that component should be an opacity of zero.
Outro Animations in Next.JS
For outro animations, intercept the page transition, and do the exit animations, then onComplete route to the next page.
To pull this off, we can use TransitionLayout higher order component as a wrapper to delay the routing change until after any animations have completed, and a TransitionProvider component that will take advantage of React’s useContext hook to share an outro timeline across multiple components, regardless of where they are nested.
Transition Context
In order to make a page transition effect, we need to prevent rendering the new page before our outro animation is done.
We may have many components with different animation effects nested in our pages. To keep track of all the different outro transitions, we will use a combination of React’s Context API and a top-level GSAP timeline.
In TransitionContext we will create our TransitionProvider which will make our GSAP timeline for outro animations available to any components who would like to transition out during a page change.
import React, { useState, createContext, useCallback } from "react"
import gsap from "gsap"
const TransitionContext = createContext({})
const TransitionProvider = ({ children }) => {
const [timeline, setTimeline] = useState(() =>
gsap.timeline({ paused: true })
)
return (
<TransitionContext.Provider
value={{
timeline,
setTimeline,
}}
>
{children}
</TransitionContext.Provider>
)
}
export { TransitionContext, TransitionProvider }
Next, we have TransitionLayout which will be our controller that will initiate the outro animations and update the page when they are all complete.
import { gsap } from "gsap"
import { TransitionContext } from "../context/TransitionContext"
import { useState, useContext, useRef } from "react"
import useIsomorphicLayoutEffect from "../animation/useIsomorphicLayoutEffect"
export default function TransitionLayout({ children }) {
const [displayChildren, setDisplayChildren] = useState(children)
const { timeline, background } = useContext(TransitionContext)
const el = useRef()
useIsomorphicLayoutEffect(() => {
if (children !== displayChildren) {
if (timeline.duration() === 0) {
// there are no outro animations, so immediately transition
setDisplayChildren(children)
} else {
timeline.play().then(() => {
// outro complete so reset to an empty paused timeline
timeline.seek(0).pause().clear()
setDisplayChildren(children)
})
}
}
}, [children])
return <div ref={el}>{displayChildren}</div>
}
In a custom App component, we can have TransitionProvider and TransitionLayout wrap the other elements so they can access the TransitionContext properties. Header and Footer exist outside of Component so that they will be static after the initial page load.
import { TransitionProvider } from "../src/context/TransitionContext"
import TransitionLayout from "../src/animation/TransitionLayout"
import { Box } from "theme-ui"
import Header from "../src/ui/Header"
import Footer from "../src/ui/Footer"
export default function MyApp({ Component, pageProps }) {
return (
<TransitionProvider>
<TransitionLayout>
<Box
sx={{
display: "flex",
minHeight: "100vh",
flexDirection: "column",
}}
>
<Header />
<Component {...pageProps} />
<Footer />
</Box>
</TransitionLayout>
</TransitionProvider>
)
}
Component-Level Animation
Here is an example of a basic animation we can do at the component level. We can add as many of these as we want to a page and they will all do the same thing, wrap all its children in a transparent div and fade it in on page load, then fade out when navigating to a different page.
import { useRef, useContext } from "react"
import { gsap } from "gsap"
import { Box } from "theme-ui"
import useIsomorphicLayoutEffect from "./useIsomorphicLayoutEffect"
import { TransitionContext } from "../context/TransitionContext"
const FadeInOut = ({ children }) => (
const { timeline } = useContext(TransitionContext)
const el = useRef()
// useIsomorphicLayoutEffect to avoid console warnings
useIsomorphicLayoutEffect(() => {
// intro animation will play immediately
gsap.to(el.current, {
opacity: 1,
duration: 1,
})
// add outro animation to top-level outro animation timeline
timeline.add(
gsap.to(el.current, {
opacity: 1,
duration: .5,
}),
0
)
}, [])
// set initial opacity to 0 to avoid FOUC for SSR
<Box ref={el} sx={{opacity: 0}}>
{children}
</Box>
)
export default FadeInOut
We can take this pattern and extract it into an extendable AnimateInOut helper component for reusable intro/outro animation patterns in our app.
import React, { useRef, useContext } from "react"
import { gsap } from "gsap"
import { Box } from "theme-ui"
import useIsomorphicLayoutEffect from "./useIsomorphicLayoutEffect"
import { TransitionContext } from "../context/TransitionContext"
const AnimateInOut = ({
children,
as,
from,
to,
durationIn,
durationOut,
delay,
delayOut,
set,
skipOutro,
}) => {
const { timeline } = useContext(TransitionContext)
const el = useRef()
useIsomorphicLayoutEffect(() => {
// intro animation
if (set) {
gsap.set(el.current, { ...set })
}
gsap.to(el.current, {
...to,
delay: delay || 0,
duration: durationIn,
})
// outro animation
if (!skipOutro) {
timeline.add(
gsap.to(el.current, {
...from,
delay: delayOut || 0,
duration: durationOut,
}),
0
)
}
}, [])
return (
<Box as={as} sx={from} ref={el}>
{children}
</Box>
)
}
export default AnimateInOut
The AnimateInOut component has built in flexibility for different scenarios:
Setting different animations, durations and delays for intros and outros
Skipping the outro
Setting the element tag for the wrapper, e.g. use a <span> instead of a <div>
Use GSAP’s set option to define initial values for the intro
Using this we can create all sorts of reusable intro/outro animations, such as <FlyInOut>, <ScaleInOut>, <RotateInOut3D> and so forth.
I have a demo project where you can see the above in practice: TweenPages

Ensure entrance css transition with React component on render

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));
}

How to render list of instances without losing state/recreating component?

Experimenting with making my own React router with some animations. Hit a brick wall.
I'm rendering a stack of screens.
The stack can be popped or pushed.
My problem is that when the stack changes the state is lost and the constructor is called again destroying the previous state (making the stack useless) .
How would I do this?
Create the screen (After this we push to the stack which is on the state)
/**
* Create a new React.Element as a screen and pass props.
*/
createNewScreen = (screenName: string, props: ?any = {}): any => {
// Props is not an object.
if (typeof props !== 'object') {
Logger.error(`Passed props to screen name ${screenName} wasn't an object or undefined. Will error next screens.`);
return;
}
let propsUnlocked = JSON.parse(JSON.stringify(props));
// Add unique screen it.
const uniqueScreenKey = this.generateRandomUniqueID();
propsUnlocked.key = uniqueScreenKey;
propsUnlocked.screenId = uniqueScreenKey;
propsUnlocked.navKing = this;
propsUnlocked.screenName = screenName;
// Find the original screen to copy from.
// This just copies the 'type'
// $FlowFixMe
return React.createElement(this.findScreenNameComponent(screenName).type, propsUnlocked);
}
Render the screens
render() {
return ( <View
{...this.props}
onLayout={(event) => this.onLayout(event)}
pointerEvents={this.state.isAnimating ? 'none' : undefined}
>
{ this.renderStackOfScreens() }
</View>);
};
renderStackOfScreens() {
// Render screens.
return this.state.stackOfScreens
.map((eachScreen, index) => {
// Render second last screen IF animating. Basically because we have a screen animating over the top.
if (index === this.state.stackOfScreens.length - 2 && this.state.isAnimating) {
return (
<Animated.View
key={eachScreen.props.screenId + '_parent'}
style={{ position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }}>
{ eachScreen }
</Animated.View>
);
}
// Render last screen which is animated.
if (index === this.state.stackOfScreens.length - 1) {
return (
<Animated.View
key={eachScreen.props.screenId + '_parent'}
style={this.getOffset(this.state.animatedScreenOffset)}>
{ eachScreen }
</Animated.View>
);
}
})
// Remove the undefined values.
.filter((eachScreen) => !!eachScreen);
}
Can see the full example here
https://pastebin.com/BbazipKt
Screen types are passed in as unique children.
Once a component is unmounted, its state is gone forever. You might think "well I have a variable reference to the component so even though it's unmounted it still keeps its state, right?" Nope, React doesn't work that way. Unmounting a component is tantamount to destroying it. Even if you remount the "same" component again, as far as React is concerned it's a brand new component, with a brand new constructor call, mounting lifecycle, etc. So you need to abandon your approach of keeping the React components themselves in arrays as the history stack.
Frustrating, I know. Believe me, I've run into the same problem.
The solution is to pull out your View/Screen states from the components themselves and lift them into a parent. In essence, you're keeping the states in an array in the parent, and then passing them as props into the Views/Screens themselves. This might seem like a counter-intuitive, "non-Reactful" way of doing things, but it actually is in line with how React is intended to be used. State should generally be "lifted up" to the level of the closest common ancestor that all components will need access to it from. In this case, you need access to your state at a level above the Views/Screens themselves, so you need to lift it up.
Here's some pseudocode to illustrate.
Right now, your app seems to be structured sorta like this:
// App state
state: {
// stackOfScreens is React components.
// This won't work if you're trying to persist state!
stackOfScreens: [
<Screen />,
<Screen />,
<Screen />
]
}
// App render function
render() {
return <div>
{
this.state.stackOfScreens.map((ea, i) => {
return <View key={i} >{ea}</View>
}
}
</div>
}
Instead it should be like this:
// App state
state: {
// stackOfScreens is an array of JS objects.
// They hold your state in a place that is persistent,
// so you can modify it and render the resulting
// React components arbitrarily
stackOfScreens: [
{
name: "screen#1",
foo: "Some sort of 'screen' state",
bar: "More state,
baz: "etc."
},
{
name: "screen#2",
foo: "Some sort of 'screen' state",
bar: "More state,
baz: "etc."
},
{
name: "screen#3",
foo: "Some sort of 'screen' state",
bar: "More state,
baz: "etc."
},
]
}
// App render function
render() {
return <div>
{
this.state.stackOfScreens.map((ea, i) => {
return <View key={i} >
<Screen stateData={ea} callback={this.screenCallback} />
</View>
}
}
</div>
}
Notice the addition of a callback prop on the Screen components that you're rendering. Thats so that you can trigger changes to the rendered Screen "state" (which is actually tracked in the parent) from within the Screen.

Resources