React-spring: Transition re-render when children component updated

Created on 15 Jun 2018  路  9Comments  路  Source: pmndrs/react-spring

Hi, based on #117 described, the modal works now.

But when I'm trying to render something in children and update it.
The transition will be triggered something like leave event, just like the following gif:

react-spring-transition

Issue example: https://codesandbox.io/s/jp8n2k0qz3

I have no idea about it 馃槙 .
Do you have any idea to fix it?

Most helpful comment

You bet. Still a couple of rough edges, especially around handleRest where state is sometimes set on an unmounted component, but otherwise does the job. If you have feedback on how to make this better, I'll definitely take it.

import { Spring, SpringProps } from "react-spring"

import { safeInvoke } from "../utils"

type TransitionAnimatedStage = "entering" | "exiting"
type TransitionRestingStage = "entered" | "exited"
type TransitionStage = TransitionAnimatedStage | TransitionRestingStage

export type TransitionRenderer = (styles: React.CSSProperties) => React.ReactNode

export interface TransitionProps {
  children: TransitionRenderer
  toggle?: boolean
  lazy?: boolean

  from: React.CSSProperties
  to: React.CSSProperties
  config?: SpringProps<{}>["config"]

  onEnter?: () => void
  onEntering?: () => void
  onEntered?: () => void

  onExit?: () => void
  onExiting?: () => void
  onExited?: () => void
}

interface TransitionState {
  mounted: boolean
  stage: TransitionStage
}

export class Transition extends React.Component<TransitionProps, TransitionState> {
  public static defaultProps = {
    toggle: false,
    lazy: true,
    config: { tension: 150, friction: 10 },
  }

  public state: TransitionState = {
    mounted: false,
    stage: "exited",
  }

  public componentDidMount() {
    const { toggle } = this.props

    if (toggle) {
      this.setState({ mounted: true, stage: "entering" })
    }
  }

  public componentDidUpdate(prevProps: TransitionProps) {
    if (!prevProps.toggle && this.props.toggle) {
      this.setState({ stage: "entering" })
    } else if (prevProps.toggle && !this.props.toggle) {
      this.setState({ stage: "exiting" })
    }
  }

  render() {
    const { children, toggle, from, to, config, lazy } = this.props
    const { mounted, stage } = this.state

    const stageSpring: Record<TransitionAnimatedStage, SpringProps<{}>> = {
      entering: {
        from,
        to,
      },
      exiting: {
        from: to,
        to: from,
      },
    }

    return (
      // When we're lazy and the transition is inactive (i.e. unmounted and toggled off), we don't
      // render anything.
      !(lazy && !mounted && !toggle) && (
        <Spring native config={config} {...stageSpring[stage]} onStart={this.handleStart} onRest={this.handleRest}>
          {children}
        </Spring>
      )
    )
  }

  private handleStart = () => {
    const { toggle, onEnter, onEntering, onExit, onExiting } = this.props
    const event = toggle ? onEnter : onExit
    const stage = toggle ? "entering" : "exiting"
    const callback = toggle ? onEntering : onExiting

    safeInvoke(event)

    this.setState({ mounted: true, stage }, callback)
  }

  private handleRest = () => {
    const { toggle, onEntered, onExited } = this.props
    const stage = toggle ? "entered" : "exited"
    const callback = toggle ? onEntered : onExited

    this.setState({ stage }, callback)

    if (stage === "exited") {
      // Unmount after a small timeout to prevent setting state on an unmounted component.
      setTimeout(() => this.setState({ mounted: false }), 10)
    }
  }
}

All 9 comments

@jigsawye if you don't give Transition keys it takes the function-child itself as a key, but since you re-create it on every render, that key changes, so for transition it looks like as if the modal actual vanished and now there's a new one instead. In other words it behaves as expected.

Two easy fixes:

A. use a reliable key, in your case the key describes if the modal is there or not:

<Transition
  keys={this.state.show}

B. move the modal into a fixed reference (i would prefer this). Remember, primitives (Spring, Transition, etc) will move props they don't recognize to the child component, so you can forward locals (setState & checked):

const Modal = ({ set, checked, ...style }) => (
  <animated.div style={{ ...style, ...content }}>
    <div>Modal Content {checked && 'Checked'}</div>
    <input type="checkbox" onChange={e => set({ checked: e.target.checked })} /> Click Me
    <button onClick={() => set({ show: false })}>close</button>
  </animated.div>
)

class App extends React.PureComponent {
  state = { show: false, checked: false }
  render() {
    return (
      <div>
        <button onClick={() => this.setState({ show: true })}>open</button>
        <Transition
          native
          from={{ opacity: 0, transform: 'translateY(-100px)' }}
          enter={{ opacity: 1, transform: 'translateY(0)' }}
          leave={{ opacity: 0, transform: 'translateY(-100px)', pointerEvents: 'none' }}
          set={args => this.setState(args)}
          checked={this.state.checked}>
          {this.state.show && Modal}
        </Transition>
      </div>
    )
  }
}

PS. Try to use the "native" flag whenever you can, especially for modals, you don't want React render that thing 60 times per second. With native set the modal will only render once and animate directly in the dom. 馃槈

@drcmda Thanks for your help, it works! 馃帀

And I already tried native in my modal, everything looks great 馃憤

@drcmda Hey, thank you for this great library! I'm having the same issue as described here and in https://github.com/drcmda/react-spring/issues/61, neither solution worked for me yet. I'm trying to transition the children prop, like so:

<Transition 
    native
    from={{ opacity: 0 }}
    enter={{ opacity: 1 }}
    leave={{ opacity: 0 }}
    keys={Number(this.props.open)}
>
    {this.props.open && (styles => <animated.div style={styles}>{this.props.children}</animated.div>)}
</Transition>

When the open prop changes before the transition had a chance to finish, the children prop gets duplicated. The keys prop doesn't help, and I'm not sure how to extract children into a fixed reference as you explained above. How would you suggest making this work?

Transition is always a stack because it guarantees exit transitions. Otherwise exit would abruptly stop and jump to enter state. If you want fade-in/out on a single element or between two you need them to be positioned absolutely, so that they can sit on top of one another, otherwise they鈥檒l push each other away, which is more useful in a list.

Ok, I've got a single element with absolute position. Is there a way to prevent duplication in the case of a single child that's dynamic content? I tried setting a static value for keys without success, whenever the transition toggles visibility before finishing, the single element gets duplicated

Sure, if you have a single Element i'd use a Spring. It's also faster since it technically leaves the node in the render tree, so you save yourself from an extra mount/unmount. With Transition you can't interrupt a child that's transitioning out. Very old react-spring versions functioned that way, but it always led to visual jank and interruptions. You could copy the Transition primitive and break animation under certain conditions - but really, i'd just use a spring instead.

Thanks for your help, @drcmda, and sorry for the delay! I was deep into it, trying to make it work.

Spring on its own didn't work out of the box since I also needed the mount/unmount behavior. I ended up writing a thin transition wrapper around the Spring primitive that allows for the animation to be interrupted and reverted in real-time, while also taking care of mounting and unmounting鈥擜PI looks just like http://reactcommunity.org/react-transition-group/transition/. Works really nicely, the Spring primitive is so powerful that I didn't have to write that much code. So, thank you again for this wonderful library 馃挅

Nice! I鈥檇 love to see this new primitive ...

You bet. Still a couple of rough edges, especially around handleRest where state is sometimes set on an unmounted component, but otherwise does the job. If you have feedback on how to make this better, I'll definitely take it.

import { Spring, SpringProps } from "react-spring"

import { safeInvoke } from "../utils"

type TransitionAnimatedStage = "entering" | "exiting"
type TransitionRestingStage = "entered" | "exited"
type TransitionStage = TransitionAnimatedStage | TransitionRestingStage

export type TransitionRenderer = (styles: React.CSSProperties) => React.ReactNode

export interface TransitionProps {
  children: TransitionRenderer
  toggle?: boolean
  lazy?: boolean

  from: React.CSSProperties
  to: React.CSSProperties
  config?: SpringProps<{}>["config"]

  onEnter?: () => void
  onEntering?: () => void
  onEntered?: () => void

  onExit?: () => void
  onExiting?: () => void
  onExited?: () => void
}

interface TransitionState {
  mounted: boolean
  stage: TransitionStage
}

export class Transition extends React.Component<TransitionProps, TransitionState> {
  public static defaultProps = {
    toggle: false,
    lazy: true,
    config: { tension: 150, friction: 10 },
  }

  public state: TransitionState = {
    mounted: false,
    stage: "exited",
  }

  public componentDidMount() {
    const { toggle } = this.props

    if (toggle) {
      this.setState({ mounted: true, stage: "entering" })
    }
  }

  public componentDidUpdate(prevProps: TransitionProps) {
    if (!prevProps.toggle && this.props.toggle) {
      this.setState({ stage: "entering" })
    } else if (prevProps.toggle && !this.props.toggle) {
      this.setState({ stage: "exiting" })
    }
  }

  render() {
    const { children, toggle, from, to, config, lazy } = this.props
    const { mounted, stage } = this.state

    const stageSpring: Record<TransitionAnimatedStage, SpringProps<{}>> = {
      entering: {
        from,
        to,
      },
      exiting: {
        from: to,
        to: from,
      },
    }

    return (
      // When we're lazy and the transition is inactive (i.e. unmounted and toggled off), we don't
      // render anything.
      !(lazy && !mounted && !toggle) && (
        <Spring native config={config} {...stageSpring[stage]} onStart={this.handleStart} onRest={this.handleRest}>
          {children}
        </Spring>
      )
    )
  }

  private handleStart = () => {
    const { toggle, onEnter, onEntering, onExit, onExiting } = this.props
    const event = toggle ? onEnter : onExit
    const stage = toggle ? "entering" : "exiting"
    const callback = toggle ? onEntering : onExiting

    safeInvoke(event)

    this.setState({ mounted: true, stage }, callback)
  }

  private handleRest = () => {
    const { toggle, onEntered, onExited } = this.props
    const stage = toggle ? "entered" : "exited"
    const callback = toggle ? onEntered : onExited

    this.setState({ stage }, callback)

    if (stage === "exited") {
      // Unmount after a small timeout to prevent setting state on an unmounted component.
      setTimeout(() => this.setState({ mounted: false }), 10)
    }
  }
}
Was this page helpful?
0 / 5 - 0 ratings

Related issues

anton-g picture anton-g  路  3Comments

chuckdries picture chuckdries  路  3Comments

mkhoussid picture mkhoussid  路  3Comments

cmmartin picture cmmartin  路  3Comments

sakhisheikh picture sakhisheikh  路  3Comments