Skip to content
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

Closed
jamesplease opened this issue Oct 31, 2019 · 8 comments
Closed

Deferred unmounting RFC #128

jamesplease opened this issue Oct 31, 2019 · 8 comments

Comments

@jamesplease
Copy link

jamesplease commented Oct 31, 2019

👋 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:

  • whether or not you think React needs something like this
  • what you think of the API ideas that area proposed here
  • any other thoughts you have on this subject

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:

  1. 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.

  2. Animating an element in. For example, a user clicking a button that causes a modal to fade in.

  3. 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:

useEffect(() => {
  animateIn();
}, []);

Likewise, animating components that are mounted is relatively straightforward, too. Here's one way:

useEffect(() => {
  animateBasedOnState(someState);
}, [someState]);

This same approach does not work for animating out, because React immediately unmounts children when the parent instructs React to do so.

useEffect(() => {
  // Sorry, this won't work. 
  return () => animateOut();
}, [];

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:

ezgif-4-f89eebb46653

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:

function Parent() {
  const [renderChild, isChildActive] = useMountTransition({
    childShouldBeMounted: true,
    exitDurationMs: 200
  });

  return (
    {renderChild && <Child isActive={isChildActive} />}
  );
}

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.

function Parent() {
  const [showChild, setShowChild] = useState(false);

  return (
    <AnimationWrapper>
      {showChild && <Child/>}
    </AnimationWrapper>
  );
}

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:

"handling deferred removal is one of the hardest things i can imagine"

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.

useDeferredUnmount(() => new Promise(resolve => setTimeout(resolve, 5000)));

It could also accept a done argument, although imo this pattern has fallen out of favor.

useDeferredUnmount(done => setTimeout(done, 5000));
Returning a Promise from useEffect
useEffect(() => {
  return () => {
    return new Promise(resolve => setTimeout(resolve, 5000));
  }
});
useEffect callback argument
useEffect(() => {
  return done => {
    setTimeout(done, 5000);
  }
});

The new hook is likely more realistic, as useEffect is not necessarily tied to a component’s mounting and unmounting lifecycle, as in:

useEffect(() => {}, [someValue]);

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.

// This example assumes `index` does not change. If it does change, then you would want to pass
// in a ref instead.
function ListItem({ index }) {
  useDeferredUnmount(done => {
    setTimeout(() => {
      animateSelfOut(done);
    }, index * 50);
  });
}

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.

function ListItem({ index, onUnmounted }) {
  useDeferredUnmount(() => {
      return animateSelfOut()
        .then(onUnmounted);
  });
}

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 both B and D 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:

useDeferredUnmount(done => {
  setTimeout(() => done({
    renderSiblings: 'after'
  }), 5000);
});

Note: this problem was originally presented here.

What about key collisions?

Imagine the same situation as above, except D has the same key as B. I think it would be OK to immediately unmount B if you reuse its key.

Note: this problem was originally presented here

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!

@drcmda
Copy link

drcmda commented Nov 2, 2019

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!

Duration

It 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 state

Unmount 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.

Order

This 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 keys

In 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.

Api

I 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 example

Turns 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.

@itsdouges
Copy link

itsdouges commented Nov 2, 2019

I like your idea for enabling this under Suspense! Tbh i've always seen exit peristence fitting neatly under the Suspense umbrella. If allowDeferredUnmount is missing it would just be the current behaviour we see today?

I also feel like there's an overlap here between useTransition (suspense hook) and what we could do for some exit persistence api (mainly the point of react keeping this around for a while).

State updates

IMO 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 animation

If we went down the path of useEffect cleanup function returning a Promise to initiate it could that make it more cumbersome to cleanup if the parent was unmounted during the exit animation? (Maybe a use case could be just smashing back in the browser)

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.

@jas7457
Copy link

jas7457 commented Nov 2, 2019

I love this idea! To me, it does fit snugly under the Supsense umbrella - Suspense suspends components from showing until they are ready, the next logical step is that it could suspend it from unmounting until it is ready.

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:

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.

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 useEffect hook.

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 useEffect is that the callback for an effect !== the component unmounting. If something in your dep array changes, the callback will be called. Under certain scenarios you may want something to animate out (some page transition, for ex), but others you may want to trigger immediately.

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.

@aleclarson
Copy link

aleclarson commented Nov 2, 2019

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 React.useTransition

@bdwain
Copy link

bdwain commented Nov 2, 2019

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.

@jamesplease jamesplease changed the title Delayed unmounting RFC Deferred unmounting RFC Nov 2, 2019
@jamesplease
Copy link
Author

jamesplease commented Nov 6, 2019

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! 📻

@brainkim
Copy link

brainkim commented Jan 3, 2020

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?

@gaearon
Copy link
Member

gaearon commented Aug 24, 2021

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.

@gaearon gaearon closed this as completed Aug 24, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

8 participants