React Transition System in under 100 lines of code using Hooks and Render Props

  • Languages
    • Javascript
  • Libraries
    • React

React Transition Group is a library that manages different stages of transition for React components. In this article, we will be building a component that imitates some of the behaviors implemented in this library.

Deterministic Finite State Machines

Premise of Finite State Machines (FSM) is very simple: output a new state based on a set of inputs and current state. Given any state, there can be multiple possiblities for transitioning. Deterministic FSM is a specialized version of FSM where there is only ONE possible transition from any state, excluding going to itself.

Transition Stages as Deterministic FSM States

Our transition system is going to have two transition paths: enter and exit. Each path consists of three stages: initial -> in progress -> done.

At initial stage, we can set initial values for our transition (e.g for fade-in animation, we set CSS opacity to 0.0). The actual transition is performed “in progress” stage. When the transition is finished, “done” stage is triggered. You can see the “state table” for our transition system below:

state next state
enter entering
entering entered
exit exiting
exiting exited

Now, let’s convert this state table into a simple function:

const stateMachine = state => {
  switch (state) {
    case 'enter':
      return 'entering';
    case 'entering':
      return 'entered';
    case 'exit':
      return 'exiting';
    case 'exiting':
      return 'exited';
    default:
      return state;
  }
};

The code above might look familiar to you if you have used Redux before. This is because reducers used in Redux are essentially state machines — they change state based on current state and input. In our case, we do only rely on current state; so, there is no need for a second argument.

Transition Component

Before we dive into main logic of the component, let’s build an interface on what the component will accept. The component needs to have the following properties:

  • inView denotes whether the transition is entering or exiting.
  • timeout determines animation / transition times.
  • children is a render prop to render the component based on current transition state. This render prop function has two arguments: current state and if component should be unmounted (we leave this argument’s usage to the developer to decide).
const Transition = ({
  children,
  inView = false,
  timeout = { enter: 2000, exit: 2000 }
}) => {
  const [state, setState] = useState('none');

  return children(state, state !== 'none' && state !== 'exited');
};

State Change Logic with Timer

Now that we have scaffolded our component and have our state machine ready, it is time to write the main logic for our component — changing state based on a timer. Let’s outline the behavior that we are going to implement:

  1. Component state starts from “enter” or “exit” (initial stage) paths based on inView prop.
  2. When the component is at the “initial” stage, the component transitions into “in progress” stage, which starts the timer.
  3. When the timer is completed, we enter the “done” stage.
const Transition = ({
  children,
  inView = false,
  timeout = { enter: 2000, exit: 2000 }
}) => {
  const [state, setState] = useState('none'); // "none" stage will be immediately changed based on inView prop
  const timer = useRef(null);

  useEffect(() => {
    clearTimeout(timer.current);
    setState(state => (inView ? 'enter' : state !== 'none' ? 'exit' : 'none'));
  }, [inView]);

  useEffect(() => {
    // If state is "none," do nothing
    if (state === 'none') return;

    // start the timer in any other stage
    timer.current = setTimeout(
      () => {
        // Find next state based on our state machine
        const nextState = stateMachine(state);
        setState(nextState);
      },
      state === 'entering'
        ? timeout.enter
        : state === 'exiting'
        ? timeout.exit
        : 0 // if state is not "in progress," don't set the timer
    );

    return () => clearTimeout(timer.current);
  }, [state, timeout]);

  return children(state, state !== 'none' && state !== 'exited');
};

In the above code, the timer starts when current stage is entering or exiting (“in progress” stages). At any other stage, transitions to the next states happen immediately.

Note that, the timer is cleared only during useEffect cleanup. This is because, cleanup function in useEffect is called not only when component unmounts but also before the next useEffect is executed. In our case, cleanup is triggered when state or timeout values are changed. If these values change in the middle of the timer, the timer will be cleared and we won’t have to worry about having an active timer.

Now, if we run our example code, the boxes will animate immediately:

const App = () => {
  const [toggle, setToggle] = useState(false);
  return (
    <>
      <button onClick={() => setToggle(prevToggle => !prevToggle)}>
        {toggle ? 'Unmount' : 'Mount'}
      </button>
      <div className="grid">
        <Transition inView={toggle}>
          {(state, mounted) =>
            mounted && (
              <div className={`box trans-2 box-green fade-${state}`}>
                Box #1
              </div>
            )
          }
        </Transition>
        <Transition inView={toggle}>
          {(state, mounted) =>
            mounted && (
              <div className={`box trans-1 box-red fade-${state}`}>Box #2</div>
            )
          }
        </Transition>
        <Transition inView={toggle}>
          {(state, mounted) =>
            mounted && (
              <div className={`box trans-05 box-orange fade-${state}`}>
                Box #3
              </div>
            )
          }
        </Transition>
      </div>
    </>
  );
};

Try it in CodeSandbox

This is a generic transition component. That means, you can do anything you like based on transition state — you can change CSS classes or styles, use imperative JS animations (e.g GSAP), or do anything you like with the state. In the example above, we are changing CSS class names of boxes based on state.

Final Words

The goal of this post is to understand underlying principles behind transitioning systems such as React Transition Group. In the future, I will be writing more posts like this to figure out what is going on behind the scenes in various libraries.