Creating a Smooth Transitioning Dialog Component in React (Part 3/4)
Tags: javascript, reactjsdevelopment, react, css
Published: 2024-07-16 12:16:52

##Part 3: Improving Animation Reliability

In [Part 2](https://dev.to/copet80/creating-a-smooth-transitioning-dialog-component-in-react-part-24-20ff), I enhanced our dialog component by adding smooth animations for minimise and expand actions using `max-width` and `max-height`. This approach ensured the dialog dynamically adapted to its content, providing fluid and natural transitions. However, one key limitation was the assumption that minimised dimensions are zero, which caused the transition to not scale down smoothly and look less natural.

Also in Part 2, I added `DialogAnimation` inside `DialogContainer` to animate `DialogBody` and `DialogFooter` individually. For Part 3, I've unified the animation to affect both components at a higher level, inside the `Dialog` component. This change simplifies the structure and eliminates the need for `animate` props in `DialogBody` and `DialogFooter` in preparation to the improvement that I was planning to make from the last approach.

Here are the changes I've made:

```jsx
// src/components/FluidDialog/Dialog.js
// The children are now wrapped within DialogAnimation
<DialogComponent
  role="dialog"
  aria-labelledby={`${dialogId}_label`}
  aria-describedby={`${dialogId}_desc`}
  ref={rootRef}
  maxWidth={maxWidth}
>
  <DialogAnimation>{children}</DialogAnimation>
</DialogComponent>
```

```jsx
// src/components/FluidDialog/DialogContainer.js
// The DialogAnimation has been removed
export default function DialogContainer({ children }) {
  const { isExpanded } = useDialog();
  return <DialogContainerComponent isVisible={isExpanded}>{children}</DialogContainerComponent>;
}
```

I'm eager to hear your thoughts on this approach and whether you find it an improvement. Your feedback will be invaluable in refining this component.

###Improvement From Last Approach
In the last approach, the assumption that minimised dimensions are zero (`max-width: 0, max-height: 0`) caused the transition to not scale down smoothly. This is because the actual minimised dimensions are never zero, leading to the transition overcompensating and making the animation look less natural.

As an improvement, I'm going to calculate both expanded and minimised dimensions so that the `DialogAnimation` component can use those dimensions to reliably transition between the two states.

####What Changes
- **Calculate Both Expanded and Minimised Dimensions**: The `DialogAnimation` component will now calculate dimensions for both expanded and minimised states. This is crucial for ensuring the animation transitions smoothly between the correct dimensions.
- **Successive Render Cycles**: To obtain accurate dimensions, the dialog needs to be expanded and minimised in successive render cycles. This allows the DOM element dimensions to be calculated using `getBoundingClientRect`.

####What Remains
- **Fluid Height**: The dialog still needs to hug the content correctly, so `max-width` and `max-height` need to be unset when the animation transition is not happening.

####Step-by-Step Dimension Calculation
Here's the step-by-step process for calculating the dimensions (consider each numbered bullet represent the nth render cycle):

1. **Expand the Dialog**: The dialog is expanded first to measure its full size.
2. **Calculate and Store Dimensions**: The dimensions are calculated using `getBoundingClientRect` and stored as `expandedDimensions`.
3. **Minimise the Dialog**: The dialog is then minimised to measure its compact size.
4. **Calculate and Store Dimensions**: The dimensions are recalculated and stored as `minimisedDimensions`.
5. **Prepare for Transition**: The dialog is set to the correct state (either expanded or minimised) with `max-width` and `max-height` set to the current state dimensions, and the `transition` property unset.
6. **Animate the Transition**: Finally, `max-width` and `max-height` are set to the target state dimensions with the `transition` property enabled, initiating the animation.

###Rewriting and Improving DialogAnimation
Now that I'm armed with that strategy, I'm going to rewrite (probably most of) the `DialogAnimation.js` code. I’ll explain it as I go, bear with me.

####Imports and Constants
```jsx
import { useState, useEffect, useRef, useTransition } from 'react';
import { styled } from 'styled-components';
import { useDialog } from './DialogContext';

const transitionSpeed = 0.3; // second
```

Nothing much changed here except that I've set a constant for the transition speed.

####Component and State Initialization

```jsx
export function DialogAnimation({ children }) {
  const containerRef = useRef(null);
  const { isExpanded, setIsExpanded, rootRef } = useDialog();
  const [isAnimatedExpanded, setIsAnimatedExpanded] = useState(isExpanded);
  const [_, startTransition] = useTransition();
  const [isAnimating, setIsAnimating] = useState(false);
  const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
  const [minimisedDimensions, setMinimisedDimensions] = useState({
    width: 0,
    height: 0,
  });
  const [expandedDimensions, setExpandedDimensions] = useState({
    width: 0,
    height: 0,
  });
  const [dimensionCheckState, setDimensionCheckState] = useState(0);
```

I set up my state variables and refs. `containerRef` points to the dialog container. `isExpanded` and `rootRef` come from `useDialog`. I use state variables to track animation states and dimensions. 

Here's a detailed breakdown of the variables and what they are used for:

- `containerRef`: A reference to the dialog container DOM element. Used to access and manipulate the DOM directly for dimension calculations.
- `isExpanded`: Tracks if the dialog is expanded.
- `rootRef`: A reference to the root element of the dialog.
- `isAnimatedExpanded`: Tracks the animated state of the dialog separately from `isExpanded`. This helps manage the animation timing and ensures smooth transitions.
- `isAnimating`: A boolean state to indicate if an animation is in progress. This is used to conditionally apply transition properties.
- `dimensions`: Stores the current dimensions (width and height) of the dialog. This is dynamically updated during the animation process.
- `minimisedDimensions`: Stores the dimensions when the dialog is minimised. Calculated and stored to ensure smooth transitions to this state.
- `expandedDimensions`: Stores the dimensions when the dialog is expanded. Calculated and stored to ensure smooth transitions to this state.
- `dimensionCheckState`: Manages the state of the dimension calculation process. This state machine controls the sequence of expanding, measuring, minimising, and animating the dialog.

#####A Quick Note on useTransition
The `useTransition` hook in React is used to manage state transitions smoothly without blocking the UI. In `DialogAnimation`, `startTransition` is employed to handle state updates, supposedly ensuring that the dialog remains responsive during dimension calculations and animations. By marking updates as transitions, React prioritizes keeping the UI fluid and preventing rendering delays. For more details, check out the official [useTransition](https://react.dev/reference/react/useTransition) documentation. (A colleague suggested this approach but I have yet benchmarked the performance. What do you think? Would it help in this case?)

####First Effect Hook: Handling Expansion State Changes

```jsx
  useEffect(() => {
    if (dimensionCheckState === 0 && isExpanded != isAnimatedExpanded) {
      const container = rootRef?.current;
      container.style.opacity = 0; // Make transparent to avoid flicker
      setIsAnimating(false);
      setIsAnimatedExpanded(isExpanded);
      setDimensionCheckState(1);
    }
  }, [isExpanded, isAnimatedExpanded]);
```

When the expansion state changes, I make the dialog transparent to avoid flickering, then update my state to start calculating dimensions.

####Main Effect Hook: Managing Dimension Calculation and Animation

```jsx
  useEffect(() => {
    const container = rootRef?.current;

    switch (dimensionCheckState) {
      // Expand
      case 1:
        startTransition(() => {
          setIsExpanded(true);
          setDimensionCheckState(2);
        });
        break;

      // Set expanded dimensions
      case 2:
        {
          const { width, height } = container.getBoundingClientRect();
          startTransition(() => {
            setExpandedDimensions({ width, height });
            setDimensionCheckState(3);
          });
        }
        break;

      // Minimise
      case 3:
        startTransition(() => {
          setIsExpanded(false);
          setDimensionCheckState(4);
        });
        break;

      // Set minimised dimensions
      case 4:
        {
          const { width, height } = container.getBoundingClientRect();
          startTransition(() => {
            setMinimisedDimensions({ width, height });
            setIsExpanded(true);
            setDimensionCheckState(5);
          });
        }
        break;

      // Prepare animation
      case 5:
        setIsAnimating(true);
        setDimensions(
          isAnimatedExpanded ? minimisedDimensions : expandedDimensions
        );
        setTimeout(() => {
          startTransition(() => setDimensionCheckState(6));
        });
        break;

      // Animate
      case 6:
        startTransition(() => {
          setDimensions(
            isAnimatedExpanded ? expandedDimensions : minimisedDimensions
          );
          setDimensionCheckState(0);
        });
        container.style.opacity = 1;

        // Finalize animation state after transition
        setTimeout(() => {
          startTransition(() => {
            setIsExpanded(isAnimatedExpanded);
            setIsAnimating(false);
          });
        }, transitionSpeed * 1000);
        break;

      // Idle
      default:
        break;
    }
  }, [dimensionCheckState, startTransition]);
```

This is where the magic happens. The effect hook manages the entire animation lifecycle through a switch case:

1. Expand the dialog.
2. Measure and store expanded dimensions.
3. Minimise the dialog.
4. Measure and store minimised dimensions.
5. Prepare for animation by setting current dimensions.
6. Perform the animation and reset states.

####Rendering the Animated Container

```jsx
  return (
    <AnimatedDialogContainer
      ref={containerRef}
      dimensions={dimensions}
      isAnimating={isAnimating}
    >
      <FixedContainer dimensions={expandedDimensions} isAnimating={isAnimating}>
        {children}
      </FixedContainer>
    </AnimatedDialogContainer>
  );
}
```

Here, I render the `AnimatedDialogContainer` and `FixedContainer`, passing the necessary props to manage dimensions and animation states.

####Styled Components

```jsx
const AnimatedDialogContainer = styled.div`
  overflow: hidden;
  transition: ${({ isAnimating }) =>
    isAnimating
      ? `max-width ${transitionSpeed}s, max-height ${transitionSpeed}s`
      : undefined};
  max-width: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.width}px` : undefined};
  max-height: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.height}px` : undefined};
`;

const FixedContainer = styled.div`
  width: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.width}px` : '100%'};
  height: ${({ dimensions, isAnimating }) =>
    isAnimating ? `${dimensions.height}px` : '100%'};
`;
```

- **AnimatedDialogContainer**: Manages the transition properties based on whether an animation is happening. The `max-width` and `max-height` are unset when it's not animating so the dialog can hug thecontent correctly.
- **FixedContainer**: Ensures the minimised content maintains its dimensions during the animation to avoid appearing squashed.

###Try the Demo!
You can access the whole source code for this approach on [CodeSandbox](https://codesandbox.io/p/sandbox/fluid-dialog-03-gjv3mt).

You can also see a live preview of the implementation below. Play around with the dynamic adaptability of the dialog and also pay close attention to the occasional (or frequent?) flickering jank when you minimise and expand the dialog.

{% embed https://codesandbox.io/embed/gjv3mt?view=editor+%2B+preview&module=%2Fsrc%2Fcomponents%2FFluidDialog%2FDialogAnimation.js %}

###Pros and Cons of This Approach
Before we wrap up, let's dive into the pros and cons of this approach compared to the one in Part 2.

####Pros
1. **Accurate Transitions**: By calculating both expanded and minimised dimensions, this approach ensures the dialog transitions to the exact right size, making the animation smooth and visually appealing.
2. **Clean Structure**: Wrapping the `DialogAnimation` around the children of the `Dialog` component simplifies the code structure, eliminating the need for individual animate props in `DialogBody` and `DialogFooter`.
3. **Dynamic Adaptability**: The dialog adapts to content changes more reliably, as dimensions are recalculated during specific render cycles.

####Cons
1. **Increased Complexity**: Managing state transitions and dimension calculations in multiple steps adds complexity to the codebase.
2. **Performance Overhead**: Expanding and minimising the dialog in successive render cycles could introduce performance overhead, particularly with frequent (complex) content changes.
3. **Jank/Flicker During Calculation**: The biggest drawback is the introduction of jank or flicker when calculating dimensions. The dialog needs to be expanded and minimised to measure with `getBoundingClientRect`, causing visible jumps in the UI.
4. **Initial Calculation Delay**: The initial dimension calculation process involves multiple steps, which may introduce a slight delay before the animation starts.

###Conclusion and Next Steps
In Part 3, I improved the `DialogAnimation` component to calculate both expanded and minimised dimensions for more accurate and visually appealing transitions. This approach involved using successive render cycles to expand and minimise the dialog, allowing for precise dimension calculations. However, it also introduced some complexity and potential performance issues, particularly the jank or flicker during dimension calculations.

####Key Takeaways:
- **Accurate Transitions**: Calculating both expanded and minimised dimensions ensures smooth animations.
- **Jank/Flicker Issue**: Expanding and minimising the dialog for dimension calculations can cause visible UI jumps.

Next, in [Part 4](https://dev.to/copet80/creating-a-smooth-transitioning-dialog-component-in-react-part-44-5236), I'll tackle the flickering issue by introducing a secondary, invisible container exclusively for dimension calculations. This approach aims to eliminate the jank while maintaining smooth and reliable transitions. Stay tuned as we continue to refine and perfect the dialog component!

I invite feedback and comments from fellow developers to help refine and improve this approach. Your insights are invaluable in making this proof of concept more robust and effective.