React: Exposing prevProps in getDerivedStateFromProps for persistent view animations

Created on 9 Jun 2018  路  10Comments  路  Source: facebook/react

Do you want to request a feature or report a bug?
Request a feature

What is the current behavior?
getDerivedStateFromProps does not expose prevProps

What is the expected behavior?
getDerivedStateFromProps should expose prevProps for cleaner implementation of use case mentioned below.

Which versions of React, and which browser / OS are affected by this issue? Did this work in previous versions of React?
react: 16.4+

I know there was a similar discussion in the issues here before regarding exposing previous props in getDerivedStateFromProps, but I believe I came across a use case where this can be useful, its very specific, yet it required me to replicate a lot of previous props in the state.

Below is a component I use in react-native to add an animation where screens crossfade and don't just unmount instantly, it also checks if next route is an overlay and preserves screen behind it. As you can see I had to create prevPathname prevData and prevChildren for this to work, which I think is not too terrible, yet results in a lot of repetition.

Perhaps my implementation is missing something to remove the repetition or maybe I am not understanding why we are not exposing prevProps?

// @flow
import React, { Component } from 'react'
import { Animated } from 'react-native'
import { durationNormal, easeInQuad, easeOutQuad } from '../services/Animation'
import type { Node } from 'react'

type Props = {
  pathname: string,
  data: ?{ overlay: boolean },
  children: Node,
  authenticated: boolean
}

type State = {
  prevPathname: ?string,
  prevChildren: Node,
  prevData: ?{ overlay: boolean },
  animation: Animated.Value,
  activeChildren: Node,
  pointerEvents: boolean,
  authAnimation: boolean
}

class RouteFadeAnimation extends Component<Props, State> {
  state = {
    prevPathname: null,
    prevChildren: null,
    prevData: null,
    animation: new Animated.Value(0),
    activeChildren: null,
    pointerEvents: true,
    authAnimation: true
  }

  static getDerivedStateFromProps(nextProps: Props, prevState: State) {
    const { pathname, data, children } = nextProps
    const { prevPathname, prevData, prevChildren } = prevState
    // This will be returned always to store "previous" props in state, so we can compare against them in
    // future getDerivedStateFromProps, this is where I'd like to use prevProps
    const prevPropsState = {
      prevChildren: children,
      prevPathname: pathname,
      prevData: data
    }
    // Check if pathname changed, i.e we are going to another view
    if (pathname !== prevPathname) {
      // Check if current visible view is a modal, if it is, we go to default return
      if (!prevData || !prevData.overlay) {
        // Check if future view is not a modal
        if (!data || !data.overlay) {
          // Preserve current view while we are animationg out (even though pathname changed)
          return {
            activeChildren: prevChildren,
            pointerEvents: false,
            ...prevPropsState
          }
        // If future view is a modal, preserve current view, so it is visible behind it
        } else if (data.overlay) {
          return {
            activeChildren: prevChildren,
            ...prevPropsState
          }
        }
      }
      // If previous view was a modal (only normal view can follow after modal) reset our view persistance
      // and use children as opposed to activeChildren
      return {
        activeChildren: null,
        ...prevPropsState
      }
    }
    // Persist prevProps in state
    return {
      ...prevPropsState
    }
  }

  // This just handles animation based on cases above
  componentDidUpdate(prevProps: Props) {
    const { pathname, data, authenticated } = this.props
    const { authAnimation } = this.state
    if (authenticated && authAnimation) this.animate(1)
    else if (pathname !== prevProps.pathname) {
      if (!prevProps.data || !prevProps.data.overlay) {
        if (!data || !data.overlay) this.animate(0)
      }
    }
  }

  animate = (value: 0 | 1) => {
    let delay = value === 1 ? 60 : 0
    const { authAnimation } = this.state
    if (authAnimation) delay = 2000
    Animated.timing(this.state.animation, {
      toValue: value,
      duration: durationNormal,
      delay,
      easing: value === 0 ? easeInQuad : easeOutQuad,
      useNativeDriver: true
    }).start(() => this.animationLogic(value))
  }

  animationLogic = (value: 0 | 1) => {
    if (value === 0) this.setState({ activeChildren: null }, () => this.animate(1))
    else this.setState({ pointerEvents: true, authAnimation: false })
  }

  render() {
    const { animation, pointerEvents, activeChildren } = this.state
    const { children } = this.props
    return (
      <Animated.View
        pointerEvents={pointerEvents ? 'auto' : 'none'}
        style={{
          opacity: animation.interpolate({ inputRange: [0, 1], outputRange: [0, 1] }),
          transform: [
            {
              scale: animation.interpolate({ inputRange: [0, 1], outputRange: [0.94, 1] })
            }
          ]
        }}
      >
        {activeChildren || children}
      </Animated.View>
    )
  }
}

export default RouteFadeAnimation

Usage example and explanation

This component is used to wrap several routes and on pathname change preserve previous view, animate it out, replace it with new view and animate it in. Idea itself comes from react-router's documentation https://reacttraining.com/react-router/native/guides/animation/page-transitions but they use componentWillMount there.

basic implementation can look like this:

<RouterFadeAnimation 
  pathname={routerProps.pathname} 
  data={routerProps.data} 
  authenticated={authProps.auth}>

     {routerProps.pathname === "/home" && <HomePage />}
     {routerProps.pathname === "/about" && <AboutPage />}

</RouterFadeAnimation>

Outside of this, there is similar component called <RouteModalAnimation /> that overlays component above, it similarly animates views in when routerProps.data has overlay: true set, you will see our original component checks for this and preserves its view so it appears behind the modal, as it would otherwise dissapear due to route change.

Discussion

Most helpful comment

Note you can also put the whole props object into state (e.g. state.prevProps) if that helps.

All 10 comments

This was discussed a lot
https://github.com/reactjs/rfcs/pull/40

Here's why we're not exposing it. There's no good value we could use that wouldn't have other drawbacks: https://github.com/reactjs/rfcs/pull/40#discussion_r180818891

Another reason is we want to discourage the use of gDSFP altogether. So it being repetitive is intentional because in most cases people don't actually need it.

I guess your case might be an exception although to be honest I don't fully understand what you're trying to do so it's hard for me to advise on how to simplify your code. Maybe if you could create an equivalent example in CodeSandbox with ReactDOM and some mock code that demonstrates the behavior you're going for it would be easier.

Maybe I can help if you put some inline comments in your gDSFP explaining what you want to happen (from user's point of view) in every if/else condition branch.

@gaearon Added inline comments, example usage and link to react-router docs where the idea was originally taken from, note I am not using react-router, this is custom routing solution, but I think linking to that page will help explain part of what I am doing here.

I've looked at https://github.com/reactjs/rfcs/pull/40 and I see what you guys are trying to achieve in terms of encouragement of how this needs to be used, however, I still feel that there will be cases where a comparison is needed between previous and incoming props and without this or access to prevProps it will require duplicating them in the state and as you can see in my example I have to store this in every return, via ...prevPropsState, hurts me inside a little 馃槄

P.S I don't think its correct to use getSnapshotBeforeUpdate here?

Transition animations based on props is one of the rare areas where gDSFP probably makes sense. So yeah, looks like you need to be using it (if you commit to this kind of implicit API for modals).

I don鈥檛 have particularly good news for you. The logic you鈥檙e trying to implement is quite complex. componentWillReceiveProps used to obscure that complexity but it was always there. getDerivedStateFromProps forces you to confront it.

I think it鈥檚 quite likely this code is buggy. It鈥檚 concerning to me that there is an unconditional return value at the end of getDerivedStateFromProps. It鈥檚 also concerning that some side effects in componentDidUpdate aren鈥檛 guarded by a check involving both previous and next props.

I think there might be ways to simplify this code. But I can鈥檛 suggest them without being able to run and debug it to better understand what exactly it is doing in practice.

@gaearon Alright, I believe this discussion is substantial enough to close the issue. Thank you for the feedback, I will add a condition that checks if last return actually needs to update those state values i.e. no if they are not changing in next props.

Same for componentDidUpdate, this was my first attempt at gDSFP, so it was a bit rushed 馃槄

One thing that helps me debug this kind of code is to put a fast interval with force update on a parent component. This forces the child to re-render more often. Check it that breaks your transitions.

That's a neat trick 馃敟ran it, no issues popped up, still added few extra checks in there, however. It all works and I made my peace with duplicating things in the state, I guess this use case requires it. Thank you very much for your time 馃憤

Note you can also put the whole props object into state (e.g. state.prevProps) if that helps.

Yeh, I actually did it that way in the beginning, but personal preference lays with specifying whats used at a specific level, hence I added those fields

Was this page helpful?
0 / 5 - 0 ratings