Skip to content Skip to sidebar Skip to footer

React Router Transition In And Out Events

I have a fairly basic setup for a small website I'm working on. I'm using React and React Router 4. Now I would like to add transition to when the user is entering a route, to tran

Solution 1:

So it turns out that an approach that supports restarting the entering transition if you switch from route 1 to route 2 and then back to route 1 while route 1 is still exiting is pretty tricky. There may be some minor issues in what I have, but I think the overall approach is sound.

The overall approach involves separating out a target path (where the user wants to go) from the rendering path (that path currently displayed which may be in a transitioning state). In order to make sure a transition occurs at the appropriate times, state is used to sequence things step by step (e.g. first render a transition with in=false, then render with in=true for an entering transition). The bulk of the complexity is handled within TransitionManager.js.

I've used hooks in my code because it was easier for me to work through the logic without the syntax overhead of classes, so for the next couple months or so, this will only work with the alpha. If the hooks implementation changes in the official release in any way that breaks this code, I will update this answer at that time.

Here's the code:

index.js

import React from "react";
import ReactDOM from "react-dom";
import App from "./App";

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

App.js

import React from "react";
import { BrowserRouter } from "react-router-dom";
import LinkOrStatic from "./LinkOrStatic";
import { componentInfoArray } from "./components";
import {
  useTransitionContextState,
  TransitionContext
} from "./TransitionContext";
import TransitionRoute from "./TransitionRoute";

const App = props => {
  const transitionContext = useTransitionContextState();
  return (
    <TransitionContext.Provider value={transitionContext}>
      <BrowserRouter>
        <div>
          <br />
          {componentInfoArray.map(compInfo => (
            <LinkOrStatic key={compInfo.path} to={compInfo.path}>
              {compInfo.linkText}
            </LinkOrStatic>
          ))}

          {componentInfoArray.map(compInfo => (
            <TransitionRoute
              key={compInfo.path}
              path={compInfo.path}
              exact
              component={compInfo.component}
            />
          ))}
        </div>
      </BrowserRouter>
    </TransitionContext.Provider>
  );
};
export default App;

TransitionContext.js

import React, { useState } from "react";

export const TransitionContext = React.createContext();
export const useTransitionContextState = () => {
  // The path most recently requested by the user
  const [targetPath, setTargetPath] = useState(null);
  // The path currently rendered. If different than the target path,
  // then probably in the middle of a transition.
  const [renderInfo, setRenderInfo] = useState(null);
  const [exitTimelineAndDone, setExitTimelineAndDone] = useState({});
  const transitionContext = {
    targetPath,
    setTargetPath,
    renderInfo,
    setRenderInfo,
    exitTimelineAndDone,
    setExitTimelineAndDone
  };
  return transitionContext;
};

components.js

import React from "react";
const Home = props => {
  return <div>Hello {props.state + " Home!"}</div>;
};
const ProjectOne = props => {
  return <div>Hello {props.state + " Project One!"}</div>;
};
const ProjectTwo = props => {
  return <div>Hello {props.state + " Project Two!"}</div>;
};
export const componentInfoArray = [
  {
    linkText: "Home",
    component: Home,
    path: "/"
  },
  {
    linkText: "Show project one",
    component: ProjectOne,
    path: "/projects/one"
  },
  {
    linkText: "Show project two",
    component: ProjectTwo,
    path: "/projects/two"
  }
];

LinkOrStatic.js

import React from "react";
import { Route, Link } from "react-router-dom";

const LinkOrStatic = props => {
  const path = props.to;
  return (
    <>
      <Route exact path={path}>
        {({ match }) => {
          if (match) {
            return props.children;
          }
          return (
            <Link className={props.className} to={props.to}>
              {props.children}
            </Link>
          );
        }}
      </Route>
      <br />
    </>
  );
};
export default LinkOrStatic;

TransitionRoute.js

import React from "react";
import { Route } from "react-router-dom";
import TransitionManager from "./TransitionManager";

const TransitionRoute = props => {
  return (
    <Route path={props.path} exact>
      {({ match }) => {
        return (
          <TransitionManager
            key={props.path}
            path={props.path}
            component={props.component}
            match={match}
          />
        );
      }}
    </Route>
  );
};
export default TransitionRoute;

TransitionManager.js

import React, { useContext, useEffect } from "react";
import { Transition } from "react-transition-group";
import {
  slowFadeInAndDropFromAboveThenLeftRight,
  slowFadeOutAndDrop
} from "./animations";
import { TransitionContext } from "./TransitionContext";

const NEW_TARGET = "NEW_TARGET";
const NEW_TARGET_MATCHES_EXITING_PATH = "NEW_TARGET_MATCHES_EXITING_PATH";
const FIRST_TARGET_NOT_RENDERED = "FIRST_TARGET_NOT_RENDERED";
const TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED =
  "TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED";
const TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING =
  "TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING";
const TARGET_RENDERED = "TARGET_RENDERED";
const NOT_TARGET_AND_NEED_TO_START_EXITING =
  "NOT_TARGET_AND_NEED_TO_START_EXITING";
const NOT_TARGET_AND_EXITING = "NOT_TARGET_AND_EXITING";
const NOT_TARGET = "NOT_TARGET";
const usePathTransitionCase = (path, match) => {
  const {
    targetPath,
    setTargetPath,
    renderInfo,
    setRenderInfo,
    exitTimelineAndDone,
    setExitTimelineAndDone
  } = useContext(TransitionContext);
  let pathTransitionCase = null;
  if (match) {
    if (targetPath !== path) {
      if (
        renderInfo &&
        renderInfo.path === path &&
        renderInfo.transitionState === "exiting" &&
        exitTimelineAndDone.timeline
      ) {
        pathTransitionCase = NEW_TARGET_MATCHES_EXITING_PATH;
      } else {
        pathTransitionCase = NEW_TARGET;
      }
    } else if (renderInfo === null) {
      pathTransitionCase = FIRST_TARGET_NOT_RENDERED;
    } else if (renderInfo.path !== path) {
      if (renderInfo.transitionState === "exited") {
        pathTransitionCase = TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED;
      } else {
        pathTransitionCase = TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING;
      }
    } else {
      pathTransitionCase = TARGET_RENDERED;
    }
  } else {
    if (renderInfo !== null && renderInfo.path === path) {
      if (
        renderInfo.transitionState !== "exiting" &&
        renderInfo.transitionState !== "exited"
      ) {
        pathTransitionCase = NOT_TARGET_AND_NEED_TO_START_EXITING;
      } else {
        pathTransitionCase = NOT_TARGET_AND_EXITING;
      }
    } else {
      pathTransitionCase = NOT_TARGET;
    }
  }
  useEffect(() => {
    switch (pathTransitionCase) {
      case NEW_TARGET_MATCHES_EXITING_PATH:
        exitTimelineAndDone.timeline.kill();
        exitTimelineAndDone.done();
        setExitTimelineAndDone({});
        // Making it look like we exited some other path, in
        // order to restart the transition into this path.
        setRenderInfo({
          path: path + "-exited",
          transitionState: "exited"
        });
        setTargetPath(path);
        break;
      case NEW_TARGET:
        setTargetPath(path);
        break;
      case FIRST_TARGET_NOT_RENDERED:
        setRenderInfo({ path: path });
        break;
      case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED:
        setRenderInfo({ path: path, transitionState: "entering" });
        break;
      case NOT_TARGET_AND_NEED_TO_START_EXITING:
        setRenderInfo({ ...renderInfo, transitionState: "exiting" });
        break;
      // case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING:
      // case NOT_TARGET:
      default:
      // no-op
    }
  });
  return {
    renderInfo,
    setRenderInfo,
    setExitTimelineAndDone,
    pathTransitionCase
  };
};

const TransitionManager = props => {
  const {
    renderInfo,
    setRenderInfo,
    setExitTimelineAndDone,
    pathTransitionCase
  } = usePathTransitionCase(props.path, props.match);
  const getEnterTransition = show => (
    <Transition
      key={props.path}
      addEndListener={slowFadeInAndDropFromAboveThenLeftRight()}
      in={show}
      unmountOnExit={true}
    >
      {state => {
        const Child = props.component;
        console.log(props.path + ": " + state);
        return <Child state={state} />;
      }}
    </Transition>
  );
  const getExitTransition = () => {
    return (
      <Transition
        key={props.path}
        addEndListener={slowFadeOutAndDrop(setExitTimelineAndDone)}
        in={false}
        onExited={() =>
          setRenderInfo({ ...renderInfo, transitionState: "exited" })
        }
        unmountOnExit={true}
      >
        {state => {
          const Child = props.component;
          console.log(props.path + ": " + state);
          return <Child state={state} />;
        }}
      </Transition>
    );
  };
  switch (pathTransitionCase) {
    case NEW_TARGET_MATCHES_EXITING_PATH:
    case NEW_TARGET:
    case FIRST_TARGET_NOT_RENDERED:
    case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITING:
      return null;
    case TARGET_NOT_RENDERED_AND_RENDER_PATH_EXITED:
      return getEnterTransition(false);
    case TARGET_RENDERED:
      return getEnterTransition(true);
    case NOT_TARGET_AND_NEED_TO_START_EXITING:
    case NOT_TARGET_AND_EXITING:
      return getExitTransition();
    // case NOT_TARGET:
    default:
      return null;
  }
};
export default TransitionManager;

animations.js

import { TimelineMax } from "gsap";
const startStyle = { autoAlpha: 0, y: -50 };
export const slowFadeInAndDropFromAboveThenLeftRight = trackTimelineAndDone => (
  node,
  done
) => {
  const timeline = new TimelineMax();
  if (trackTimelineAndDone) {
    trackTimelineAndDone({ timeline, done });
  }
  timeline.set(node, startStyle);
  timeline
    .to(node, 0.5, {
      autoAlpha: 1,
      y: 0
    })
    .to(node, 0.5, { x: -25 })
    .to(node, 0.5, {
      x: 0,
      onComplete: done
    });
};
export const slowFadeOutAndDrop = trackTimelineAndDone => (node, done) => {
  const timeline = new TimelineMax();
  if (trackTimelineAndDone) {
    trackTimelineAndDone({ timeline, done });
  }
  timeline.to(node, 2, {
    autoAlpha: 0,
    y: 100,
    onComplete: done
  });
};

Edit nrnz2lvnxj


Solution 2:

I've left this answer in place so that the comments still make sense and the evolution is visible, but this has been superseded by my new answer

Here are a few related references which I expect you have looked at some of:

https://reacttraining.com/react-router/web/api/Route/children-func

https://reactcommunity.org/react-transition-group/transition

https://greensock.com/react

code sandbox with the code included below, so you can quickly see the effect

The code below uses Transition within a Route using the addEndListener property to plug in the custom animation using gsap. There are a couple important aspects for making this work. In order for Transition to go through the entering state, the in property must go from false to true. If it starts at true, then it will skip immediately to the entered state without a transition. In order for this to occur within a Route, you need to use the children property of the Route (rather than component or render) since then the children will be rendered regardless of whether the Route matches. In the example below you will see:

<Route exact path="/projects/one">
    {({ match }) => <Projects show={match !== null} />}
</Route>

This passes a boolean show property to the component which will only be true if the Route matches. This will then be passed as the in property of the Transition. This allows Projects to start with in={false}, rather than (when using the Route component property) starting out as not rendered at all (which would prevent a transition from occurring because it would then have in={true} when it is first rendered).

I didn't fully digest all of what you were trying to do in componentDidMount of Projects (my example is significantly simplified, but does do a multi-step gsap animation), but I think you will be better off to use Transition to control the triggering of all your animations rather than trying to use both Transition and componentDidMount.

Here's the 1st code version:

import React from "react";
import ReactDOM from "react-dom";
import { Transition } from "react-transition-group";
import { BrowserRouter, Route, Link } from "react-router-dom";
import { TweenLite, TimelineMax } from "gsap";

const startState = { autoAlpha: 0, y: -50 };
const onEnter = node => TweenLite.set(node, startState);
const addEndListener = props => (node, done) => {
  const timeline = new TimelineMax();
  if (props.show) {
    timeline
      .to(node, 0.5, {
        autoAlpha: 1,
        y: 0
      })
      .to(node, 0.5, { x: -25 })
      .to(node, 0.5, {
        x: 0,
        onComplete: done
      });
  } else {
    timeline.to(node, 0.5, {
      autoAlpha: 0,
      y: 50,
      onComplete: done
    });
  }
};
const Home = props => {
  return (
    <Transition
      unmountOnExit
      in={props.show}
      onEnter={onEnter}
      addEndListener={addEndListener(props)}
    >
      {state => {
        return <div>Hello {state + " Home!"}</div>;
      }}
    </Transition>
  );
};
const Projects = props => {
  return (
    <Transition
      unmountOnExit
      in={props.show}
      onEnter={onEnter}
      addEndListener={addEndListener(props)}
    >
      {state => {
        return <div>Hello {state + " Projects!"}</div>;
      }}
    </Transition>
  );
};

const App = props => {
  return (
    <BrowserRouter>
      <div>
        <br />
        <Link to="/">Home</Link>
        <br />
        <Link to="/projects/one">Show project</Link>
        <br />
        <Route exact path="/">
          {({ match }) => <Home show={match !== null} />}
        </Route>
        <Route exact path="/projects/one">
          {({ match }) => <Projects show={match !== null} />}
        </Route>
      </div>
    </BrowserRouter>
  );
};

const rootElement = document.getElementById("root");
ReactDOM.render(<App />, rootElement);

Update 1: Addressing question #1 in your update. One nice thing with react-router version 4 is that routes can appear in multiple places and control multiple parts of the page. In this code sandbox, I've done an update of your code sandbox to have the Home link switch between a Link and static text (though you could change this to use styling such that the look is the same for both). I replaced the Link with LinkOrStaticText (I did this quickly and it could use some refining to handle passing props through more robustly):

const LinkOrStatic = props => {
  const path = props.to;
  return (
    <Route exact path={path}>
      {({ match }) => {
        if (match) {
          return props.children;
        }
        return (
          <Link className={props.className} to={props.to}>
            {props.children}
          </Link>
        );
      }}
    </Route>
  );
};

I'll do a separate update to address question 2.

Update 2: In trying to address question 2, I discovered some fundamental issues with the approach I was using in this answer. The behavior was getting muddled because of multiple routes executing at once in certain cases and issues from odd remnants of unmounted transitions that were in-progress. I needed to start over largely from scratch with a different approach, so I'm posting the revised approach in a separate answer.


Post a Comment for "React Router Transition In And Out Events"