Dynamic transitions with react-router and react-transition-group

Here is a demo and the associated source code of what is explained in this article.

The problem

React-router and react-transition-group are two widely used librairies that can be combined to create transitions between routes.

However, in react-transition-group, once a component is mounted, its exit animation is specified, not changeable. Thus, dealing with transitions depending of the next state (what I call dynamic transitions) is challenging with this library.

The exiting transition of state A does not depend only from state A (“dynamic transition”)

Although a simple example is available on react-router doc, it is not easy to tweak it to create more sophisticated use cases such as dynamic transitions. In this article, I’ll explain how to do so thanks to react-router v4 and react-transition-group v2.

1. Understanding the simple example

If you are on your way developing transitions between pages of your react app, you might have already met this code snippet from react-router doc (adapted with state A/B):

<TransitionGroup>
<CSSTransition key={location.key} classNames="fade" timeout={300}>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
</TransitionGroup>

Understanding why this piece of code allows a transition between two routes is not obvious. However, it is necessary to implement a more sophisticated use case such as dynamic transitions.

About TransitionGroup

I will rephrase what is already written in this article: a shallow dive into react router animated transitions.

When transitioning from state A to state B, location.key value changes (let's say from A to B) so without a <TransitionGroup> wrapping <CSSTransition key={location.key}> , the <CSSTransition key='A'> would be unmounted and a new <CSSTransition key='B'> would be mounted (because react identifies elements thanks to key).

However, <TransitionGroup> tracks its children by key and when one of its children disappears, it keeps rendering it for the time of the transition. So during the time of the transition, the above TransitionGroup would render something similar to this:

<div>
<CSSTransition key='A' leaving>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
<CSSTransition key='B' entering>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
</div>

About Switch

You simply need to understand that when location.pathname is /state-a, this:

<Switch>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>

it renders:

<A />

Why you need to pass a location prop to the switch?

By default, a switch uses history.location to select the route to render. However you can provide a location prop to the switch that will override the default history.location value:

<TransitionGroup>
<CSSTransition key={location.key} classNames="fade" timeout={300}>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
</TransitionGroup>

So why this location (provided by withRouter or available within a route component) must be added as a prop to the switch in a transition use case? (see the origin of this requirement, in this issue)

history.location is a live object whereas the location provided by withRouter is immutable (see doc). Thus, without providing a location prop to the switch, the switch would always match the route according to the current location (the location of history.location). So during the transition (the current location is B), the <TransitionGroup> would render:

<div>
<CSSTransition key='A' leaving>
<B />
</CSSTransition>
<CSSTransition key='B' entering>
<B />
</CSSTransition>
</div>

However, if you pass a location to the switch, the switch will use this prop instead of history.location and since location is immutable, the previous <CSSTransition> received the previous location and the new <CSSTransition> receives the new location.

<div>
<CSSTransition key='A' leaving>
<A />
</CSSTransition>
<CSSTransition key='B' entering>
<B />
</CSSTransition>
</div>

Thereby the leaving <CSSTransition> will still render an old route even if a new location has been pushed to the history.

2. Dealing with dynamic transitions

Dealing with dynamic transitions is not straight forward. An issue on react-transition-group is open to consider this problem.

As explained in the issue:

once a component is mounted, its exit animation is specified, not changeable.

Indeed, in this code snippet:

<TransitionGroup>
<CSSTransition key={location.key} classNames="fade" timeout={300}>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
</TransitionGroup>

only the current (entering) child is accessible. The exiting one has already been removed. It is only living within the <TransitionGroup> state.

Fortunately the <TransitionGroup> component can receive a childFactory. The doc says:

If you do need to update a child as it leaves you can provide a childFactory to wrap every child, even the ones that are leaving.

So the childFactoryprop makes it possible to specify the leaving transition of a component after rendering it (and thus solves the problem of dynamic transitions)

<TransitionGroup
childFactory={child => React.cloneElement(
child,
{classNames: "newTransition", timeout: newTimeout}
)}

>
<CSSTransition key={location.key}>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
</TransitionGroup>

In the above code snippet, the previous <CSSTransition> will be updated with the new transition class name and timeout.

A possible implementation of dynamic transitions

The question is now: how do you give the right classNames value according to the state transition?

A possible solution is to use the location state.

About location state in the location doc:

Normally you just use a string, but if you need to add some “location state” that will be available whenever the app returns to that specific location, you can use a location object instead. This is useful if you want to branch UI based on navigation history instead of just paths (like modals).

Here is how you could do:

// state-a.js
export default (props) => (
<div>
<button
onClick={() => {
history.push({
pathname: '/state-b',
state: {transition: 'fade', duration: 300}
})

}}
>Go to state B</button>
<button
onClick={() => {
history.push({
pathname: '/state-c',
state: {transition: 'slide', duration: 500}
})

}}
>Go to state C</button>
</div>
)

In the routes definition file:

<TransitionGroup
childFactory={child => React.cloneElement(
child,
{
classNames: location.state.transition,
timeout: location.state.duration
}
)}
>
<CSSTransition key={location.key}>
<Switch location={location}>
<Route exact path="/state-a" component={A} />
<Route exact path="/state-b" component={B} />
</Switch>
</CSSTransition>
</TransitionGroup>

Now you should get 2 different transitions from the same exiting state 🎉🎉🎉. This is what we were trying to solve :-).

Demo + source code

Annexe A: Wrap your pages in a div

You should wrap your switch in a div until this issue is solved:

<CSSTransition>
<div>
<Switch>
...
</Switch>
</div>
</CSSTransition>

Otherwise you’ll get an uncaught error if any of the route you define renders null.

Annexe B: CSSTransition and styled-components

As CSS-in-JS is now a standard in react development, you might be looking for a solution to handle <CSSTransition> with CSS-in-JS. Here is my solution:

// fade.js
import { injectGlobal } from ‘styled-components’
const transitionClassName = ‘fade’
const duration = 400
injectGlobal`
.${transitionClassName}-enter {
opacity: 0;
}
.${transitionClassName}-enter.${transitionClassName}-enter-active {
opacity: 1;
transition: all ${duration}ms;
}
.${transitionClassName}-exit {
opacity: 1;
}
.${transitionClassName}-exit.${transitionClassName}-exit-active {
opacity: 0;
transition: all ${duration}ms;
}
`
export default { transition: transitionClassName, duration }
Like what you read? Give Nicolas Girault a round of applause.

From a quick cheer to a standing ovation, clap to show how much you enjoyed this story.