-
Notifications
You must be signed in to change notification settings - Fork 558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Deferred unmounting RFC #128
Comments
This would be extremely useful. Unmount is still one of the hardest things to get right. From a libraries perspective is very bug prone due to ordering/keying. And in user-land it's such a contrasting concept that users find it very hard to wrap their heads around it. If react could handle this it would be amazing! DurationIt should not be related to time, other than perhaps a cut off threshold. A components demise can be driven by springs (which rely on force and friction, not time) fetch requests or other factors. It should either be a promise or a callback. Retaining stateUnmount transitions are not just about delayed removal, they are essentially state retainers. For instance, a route that unmounts clings to the old route instead of the new one that replaced it. If that should work automatically, the component has to be cut off from all dynamics, it shouldn't be allowed to receive or alter state, context, etc. Not sure about local setState, can this be detected? Would be nice if that still worked. Example: https://codesandbox.io/s/jp1wr1867w?from-embed export default function App() {
const location = useLocation()
const transitions = useTransition(location, location => location.pathname, {
from: { opacity: 0, transform: 'translate3d(100%,0,0)' },
enter: { opacity: 1, transform: 'translate3d(0%,0,0)' },
leave: { opacity: 0, transform: 'translate3d(-50%,0,0)' },
})
return transitions.map(({ item: location, props, key }) => (
<animated.div key={key} style={props}>
<Switch location={location}>
<Route path="/" exact component={A} />
<Route path="/a" component={A} />
<Route path="/b" component={B} />
<Route path="/c" component={C} />
</Switch>
</animated.div>
))
} It fetches a location and gives that to the value retainer (useTransition). Down in the view it uses that retained location, not the fresh one, otherwise it would display the new route while fading out. OrderThis was one of the hardest things to get right for us, and it was massively complicated and bug prone. A B C render, B gets taken out, the user renders A (B?) D (B?) C. And it gets worse the more the user alters that order with successive render passes. I would also prefer something very simple and limited and leave ordering mostly to user-land (flex, etc). Doubled keysIn react-spring our transition would generate keys. So that when A goes out, and comes in again, both can stand side by side. In V9 we have decided it's time to remove it in favour of simpler code and safety. The deferred first A in this case would just be removed, which is fine for most cases. ApiI think it should be added to suspense and disabled by default. It would be pretty weird if this isn't under the users full control otherwise, when components can just decide to stick around on their own. function A() {
const [message, set] = useState("i'm mounted")
useEffect(() => () => new Promise(res => {
set("i'll unmount ...")
setTimeout(res, 5000)
}), [])
return <div>{message}</div>
}
// Will be removed immediately
<Suspense fallback={null}>
<A />
</Suspense>
// Components can take as much time as they want to resolve
<Suspense fallback={null} allowDeferredUnmount>
<A />
</Suspense>
// Components have 5 seconds to resolve their promise before they're taken out by force
<Suspense fallback={null} allowDeferredUnmount={5000}>
<A />
</Suspense> IMO function callback removal isn't backwards compatible useEffect(() => done => ..., []) How can it know if the cleanup function picks up "done" and intents to use it? A promise seems safer and guarantees that the component is about to defer. Real world exampleTurns out DYO implements it already: https://dyo.js.org/advanced.html#async-unmount They solve the rendering order by simply removing the component from the virtual dom, letting the host handle order by itself via appendChild/insertBefore/etc. This would remove all the complexity. https://twitter.com/thysultan/status/1190555205638774784 With the removal from the virtual dom the retaining issue is solved as well, it's thereby stale/non-reactive. |
I like your idea for enabling this under Suspense! Tbh i've always seen exit peristence fitting neatly under the Suspense umbrella. If I also feel like there's an overlap here between State updatesIMO allowing local state updates is a must, think about the various ways user land people would want to apply it. Realistically I'd think of two ways - set state to start the exit animation or using a ref to do something directly to the element. Parent unmounting during a child exit animationIf we went down the path of How could we inform the child of what's happening? (Maybe they want to cancel - maybe they want to change their exit animation, who knows!) Would it then make more sense to go down the new hook route? That kind of just looks like useEffect. const element = useRef();
const animation = useRef(element);
usePersistence((done) => {
// block ran on unmount
animation.start().then(done);
return () => {
// cleanup?
animation.end();
};
// opts for extra config. timeout etc maybe.
}, opts); It would be interesting to see how DYO looked at these problems 😄 Excited this is getting some notice! Thanks for raising it. Keen to see what the core teams thinking is around this. |
I love this idea! To me, it does fit snugly under the I'm still going through the docs and learning about the new experimental features/channel, so I'm not sure what makes the most sense API-wise (should this be an attribute to the Suspense component, should it be a new hook, should we mutate an existing hook?) I would like to play devil's advocate here a bit though. @drcmda asked:
This pattern is fairly common in unit test frameworks like Mocha and Jasmine, but is not a pattern that I particularly like. You can check if a function accepts a parameter, but can't infer if it intends to use it. function fnLength(fn){
console.log(fn.length);
}
fnLength(function(){}); // 0
fnLength(function(done){}); // 1 So while it's technically possible to know if their callback accepts a parameter, I'm not a fan of this pattern. To me, a separate hook might make more sense as opposed to overloading an already established Your alternative proposal was to use Promises. This is my preferred solution over the done callback, with a caveat. React has really good browser compatibility. It works out of the box with IE11 and I believe back to 9 with a couple small es5 shims. Introducing a direct reliance on Promises may be antithetical to their strive for good browser compatibility. Can we be clear and call these Promise-like and/or thenables? One other gripe I have about coupling this with With all that being said, I think the API should either exist as a new hook or as parameters to Suspense which @drcmda gave examples for. |
I think I've found an API design that fits the shoe. Curious to hear any objections. import React from 'react'
import { animated, useSpring } from 'react-spring'
const Example = () => {
const [unmount, isUnmounting] = React.useUnmount()
const { opacity } = useSpring({
// Fade in "opacity" from 0
from: { opacity: 0 },
// Fade out "opacity" to 0 when ready to unmount
opacity: isUnmounting ? 0 : 1,
// Commit delayed unmount when fade out ends
onRest: () => unmount(),
})
return <animated.div style={{ opacity, width: 100, height: 100, background: 'red' }} />
} As you can see, it borrows its return signature from |
I think it’s important to allow an element to delay unmounting even if its parent is unmounting as well. That use case has been way more complicated for me and libraries like react transition group weren’t able to solve the issue. The use case where this has come up for me is a list item with a button in it, where clicking the button dispatches a redux action that causes some async work to be done (instead of doing it right in the component). On success, the item is removed from the list. Before it is removed, I'd like to give feedback that the action was successful by showing a checkmark in the button for a second. Ideally, this logic would live in a button component, making it really easy to reuse anywhere you have a button. But in order for that to work, the button component itself needs to be able to delay its own unmounting. Currently, this has to be done by preventing all parents of the button from being unmounted also. My current implementation handles this all in redux by delaying the state updates caused by the async work. That logic is purely for display purposes though, and doesn't really belong there. It also makes it hard to replicate that behavior across different types of lists. |
Thanks for the insightful comments and support, everyone! Paul (the author of react-spring), Matt (the author of framer-motion), and I are working on taking the ideas from this conversation and turning it into an RFC. The RFC we're thinking of will differ in some ways from what's presented here, but it's in the same spirit. Importantly, it will elaborate more on the implementation details. It also seems like it will be possible for me to patch React to match the RFC (in an incredibly hacky way 😅 ), so ideally the RFC will be accompanied by examples showing how the proposal meets all of our exit animation needs. Stay tuned! 📻 |
Hi. Curious as to what people think should happen if a parent is unmounted when it has children which asynchronously unmount? Should the parent wait for the children to exit before itself unmounting? Should the parent unmount first? Should async children be ignored entirely? |
Hi, thanks for your suggestion. RFCs should be submitted as pull requests, not issues. I will close this issue but feel free to resubmit in the PR format. |
👋 Hey there React team and community!
This is something I'd like to see as an RFC, but because I am certain that there are more knowledgeable folks than me on this subject, I'd like to to hear the thoughts of others in the community before I draft something up.
Ideally there would be a lively conversation like the one that happened over in #104 .
I'm curious for your feedback on:
As a heads up: think of all of the names in the API suggestions in here as placeholders. Determining the right names will be an important step in this process, but I think it would be more fruitful to consider the behavior of the APIs at this stage in the conversation.
Thanks for stopping by!
Motion Design
Motion design is an important part of UX. When done well, it reinforces other feedback mechanisms in the app. Providing feedback to users is crucial, as it helps them understand what is happening as they interact with the interface.
For the sake of discussion we can categorize animations in the following way:
Animations that occur while the element is already visible onscreen. For instance, perhaps it scales up on hover, and then scales down on mouse out.
Animating an element in. For example, a user clicking a button that causes a modal to fade in.
Animating an element out. For instance, clicking a "Cancel" button within the modal, causing it to fade out.
Motion in React
React does not provide built-in APIs to aid with animations, so it is up to the community to implement them with libraries such as framer-motion and react-spring.
Although I personally believe it would be best if React were animations-first across the board, I recognize that that is unrealistic (it is a challenge to find examples of motion on facebook.com / messenger.com), so this RFC will focus on solving what I think is the biggest problem with React's lack of animations API: animating components out.
Let's first look at how one might animate a component in, or animate an already-mounted component in React.
Animating components in is relatively straightforward: mount the component in an "out" state, and then immediately transition it to an "in" state after it mounts. This can be done many different ways; here's one example:
Likewise, animating components that are mounted is relatively straightforward, too. Here's one way:
This same approach does not work for animating out, because React immediately unmounts children when the parent instructs React to do so.
It is a bit more complex to support animations out, and because of that, I sometimes see situations where developers will implement an "in" transition but ignore the "out" transition. A tooltip may slide down and fade into view on hover, but then immediately vanish on mouse out, as an example.
Here is an example from frame.io, which appears to be a React app, of a menu that has an in animation but no accompanying out animation:
frame.io is a phenomenal app, and it is clear that the designers and engineers prioritize visual and motion design in their product. It is telling, I think, that an app with that much attention to detail does not have exit transitions.
How to Animate Out Today
There are a few ways to do this in applications today. The solutions all involve bringing the parent component into the picture, since it must keep its child mounted until the animation completes.
An example Hooks API that can help with this is:
react-spring includes a considerably more sophisticated hook to manage exit transitions,
useTransition
.In other popular animation libraries for React, another pattern is a wrapping component that can hold onto the children, even after the parent unmounts them.
Why should React help with this?
Here are three reasons that I find compelling:
The first is that it is difficult to implement. Two libraries that implement it as a wrapping component are framer-motion and react-transition-group. 25% of all framer-motion issues include the name of that lib's wrapping component,
AnimatePresence
, and 37% of react-transition-group's reference that lib's wrapping component. Many of these bugs aren't just users learning a challenging API: they are problems in the library caused by the inherent difficulty in implementing this feature.Here is the author of react-spring on their solution:
Wow 😬
The second reason is developer ergonomics. I think it suboptimal that of the categories of animations described above, one of the categories requires a radically different API from the others. And that different API is usually hard to understand and use correctly. It would likely be easier for developers to learn and implement animation in their apps if there was a more symmetrical API when it comes to mounting and unmounting transitions.
The third reason is that I consider exit animations to be such a fundamental feature of web apps that React (and any other UI framework) should have a good story for it. Developers shouldn't need to pull in external libraries to add exit animations.
Example Solutions
In all of the following examples, the component would remain mounted for 5 seconds after the parent attempts to unmount it. I suspect that of these, the ones that use Promises would be what most developers would prefer. That may also play nicely with the React architecture being introduced with Suspense / Concurrent React.
A new hook
(Consider the name to be a placeholder)
This new hook accepts a Promise as a return value.
It could also accept a
done
argument, although imo this pattern has fallen out of favor.Returning a Promise from
useEffect
useEffect
callback argumentThe new hook is likely more realistic, as
useEffect
is not necessarily tied to a component’s mounting and unmounting lifecycle, as in:Things to Consider
What about prop updates after the component unmounts?
Perhaps it makes sense for the component to not receive any changes to its props after it has been unmounted. If there are values that may change that you need to reference, then you could use refs.
What about context updates?
Same as the above.
What if an unmounting component updates its own state?
I'm not sure what would be best. My inclination is that React should ignore the update and not update the DOM after the unmounting process begins.
What about orchestrating animations? i.e.; cascading the items in a list out.
Passing in the right props, such as
index
, should allow you to delay the exit animation in a way that creates a cascading effect.Sometimes developers will want to make sure that only one child is mounted at a time. After one child finishes unmounting, the parent Component mounts the next one. How could that be accomplished?
One way to do this would be to use callbacks to let the parent know when the child has unmounted.
The parent can keep track of when to render the next component by managing its own state.
What if the parent unmounts while the child is delaying its unmounting?
One idea is that the child's animation would be immediately canceled. That way, you can opt into delaying unmounting the parent by using state to keep track of whether the child has completed its exit or not.
What about ordering of children?
Let me elaborate a bit more on this. Consider a parent with children
[A, B, C]
. It unmounts B, but B delays its unmounting. In the meantime, it mounts a new component, D, where B was. So to the parent, we have:[A, D, C]
. However, in the DOM, we have bothB
andD
visible. Which one comes first? Is it[A, B, D, C]
or[A, D, B, C]
?I'm not sure of all of the implications of this, but I think that placing any new nodes after all of the old nodes would be an okay place to start. It would be up to the developer to rearrange things (i.e.; with
flex-order
).In my opinion, ordering the visual appearance of elements on a page is less of a challenge than delaying unmounting.
There's the potential for a11y concerns with this approach, and I am still have much to learn when it comes to a11y, but perhaps aria-flowto might be helpful for when the DOM ordering doesn't reflect the visual order.
Another idea (a bad one, maybe) would be to provide React with some guidance on what to do. Consider:
What about key collisions?
Imagine the same situation as above, except
D
has the samekey
asB
. I think it would be OK to immediately unmountB
if you reuse its key.What about canceling the unmount?
I don't think that React should support "canceling" the unmounting of a child with this API.
Could this be related to Suspense?
There's the opportunity to tie this into Suspense, particularly given that the API that uses Promises. One potential downside to reusing the same exact Component,
Suspense
, is that folks might opt into two separate behaviors (suspended rendering and suspended unmounting) at the same time.I'm new to Suspense, so I defer (hehe) this to the React team.
What if a developer never tells React to unmount the component?
Well, that would be bad. Don't do that.
Nah, but in all honesty, I'm not too sure what would be best in this case. I suppose it would just stick around in the DOM. Maybe React could warn you if enough time passes and a component is just hanging out? Or perhaps there could be some way to configure a maximum exit time, and after that time anything still in the DOM will unmount (say, 10 seconds).
If the API ties into the Suspense component, then a max time could be specified as a prop on the Suspense component. (props to @drcmda for this idea)
Previous Discussions
Are there other considerations that make this idea untenable? Do you have ideas for a better API? Are the existing solutions good enough? Let me know what you think in the comments below!
The text was updated successfully, but these errors were encountered: