Inside Parallax, my first Framer X component, Part 2

Last Updated:
gif of parallax

In this series of posts, I want to give you a technical drill down of the parallax package I built for Framer X. Here are some key points that are covered:

  • last post:
    • how to debug in Framer X
    • how to reuse property controls of an existing component, such as Scroll
    • how to add a connector to a component, just like the one Scroll has
    • how to monitor the scrolling position
  • this post:
    • how to recursively walk through a children tree and make changes
    • how to use Animatables to avoid excessive cloning
    • how to use the Context API in React to further improve the performance ``

Review: how does Parallax work?

Remember how Parallax works? We have two components, ParallaxScroll and ParallaxLayer. ParallaxScroll monitors how much the page has been scrolled, finds out all ParallaxLayers in the children, calculates their positions based on their speed props and the current scroll position, and update their positions.

The code skeleton may look like this:

export class ParallaxScroll extends Component {
  ...
  handleScroll = ({x, y}) => {
    // find all ParallaxLayers in this.props.children
    // and update their positions according to x or y
  }
  render() {
    return (
      <Scroll {...this.props} onMove={this.handleScroll}>
        {this.props.children}
      </Scroll>
    );
  }
}

But how do we find the ParallaxLayers and update their props? This is the focus of this post.

As mentioned, I've tried three approaches:

  1. Clone the children tree when scrolling (slooooow!)
  2. Use Animatables to avoid excessive cloning (faster)
  3. No cloning at all with React Context (awesome!)

Before going through these approaches though, let's visit a mistake that you'd probably make if you are new to React. Honest confession: I did something similar when I started learning React!

The first attempt

Since this.props.children is a tree structure, why can't we just traverse it, find the ParallaxLayers and update the props along the way?

This seems like a straightforward process with recursion:

const isParallaxLayer = (node) => ...;

const updateParallaxLayerProps = (node) => ...;

const updateParallaxLayers = (node) => {
  if (isParallaxLayer(node)) {
    updateParallaxLayerProps(node);
  }
  React.Children.toArray(node.props.children).forEach(c =>
    updateParallaxLayers(c)
  );
};

In updateParallaxLayerProps, perhaps we have something like this, right?

node.setProp('style', { left, top })
// or:
node.props.style = { left, top }

Unfortunately, there's no setProp method and this approach won't work at all. This approach goes against the core ideas of React. In React, props are immutable. We can't update them in-place like so in updateParallaxLayerProps!

Heck, if the props in React can't be changed, how do we update our UI? There's certainly something that has to change!

The solution is to create a new copy of the element with the new props:

const newNode = React.cloneElement(node, newProps)

Here this cloneElement function creates a copy of node with exactly the same tag name, props and children, except that it'll replace the props included in newProps.

We can replace its children as well:

const newNode = React.cloneElement(node, newProps, newChildren)

We'll then use the newNode in the render method of the component. This leads to my first approach that actually works (albeit rather slow).

Approach 1: Clone the tree when scrolling

The code of this approach is on this branch.

Clone the tree

Because the children tree is immutable, if we want to update the props of any element in it, we'd have to clone the entire tree!

export class ParallaxScroll extends Component {
  ...
  render() {
    const child = this.props.children[0];
    return (
      <Scroll {...this.props} onMove={this.handleScroll}>
        {this.cloneAndOffsetParallaxLayers(child)}
      </Scroll>
    );
  }
}

The cloneAndOffsetParallaxLayers method looks like this:

cloneAndOffsetParallaxLayers(child) {
  const getUpdatedPositionProps = (node) => {
    if (isParallaxLayer(node)) {
      // return new position props according to scroll position
    } else {
      return null; // won't update props
    }
  }
  return cloneAndUpdateProps(getUpdatedPositionProps, child)
}

To make the cloning function reusable, the logic for getting the new props is exacted into the getUpdatePropsFun callback function.

The cloneAndUpdateProps function uses recursion to clone the entire tree with updated props for each node along the way:

function cloneAndUpdateProps(getUpdatePropsFun, node) {
  if (!React.isValidElement(node)) return node
  const updateProps = getUpdatePropsFun(node)
  const clonedChildren = React.Children.map(node.props.children, (c) =>
    cloneAndUpdateProps(getUpdatePropsFun, c),
  )
  return React.cloneElement(node, updateProps, clonedChildren)
}

Update when scrolling

Now let's see how to update the positions of ParallaxLayers when scrolling:

export class ParallaxScroll extends Component {
  ...
  handleScroll = ({x, y}) => {
// find all ParallaxLayers in this.props.children
// and update their positions according to x or y
} render() { return ( <Scroll {...this.props} onMove={this.handleScroll}> {this.cloneAndOffsetParallaxLayers(child)} </Scroll> ); } }

In the handleScroll function above, we'll receive the scroll position x and y. We can just set it to the state:

export class ParallaxScroll extends Component {
  ...
  state = {
    scrollX: 0,
    scrollY: 0
  }
  handleScroll = ({x, y}) => {
    this.setState({scrollX: x, scrollY: y});
  }
  ...
}

In the cloneAndOffsetParallaxLayers method, we'll use this state to work out the new position props:

cloneAndOffsetParallaxLayers(child) {
  const getUpdatedPositionProps = (node) => {
    if (isParallaxLayer(node)) {
      ...
      const scrollPosition =
          this.props.direction === "vertical"
? this.state.scrollY
: -this.state.scrollX;
const left = (scrollPosition * speedX) / 10; const top = (scrollPosition * speedY) / 10; return {style: {left, top}}; } else { return null; } } return cloneAndUpdateProps(getUpdatedPositionProps, child) }

So that's about it! Whenever the ParallaxScroll is scrolled, we'll update the state. As the result, we re-render the whole thing: clone and update the positions of all ParallaxLayers.

I know, this sounds really wasteful. Although the React elements we clone are plain objects which are relatively cheap to create, doing so on every render still doesn't sound like a good idea.

In fact, the scrolling experience of this component looks like this:

Approach 2: Use Animatable to avoid excessive cloning

The code of this approach is on this branch.

Use Animatable

The major issue of the first approach is the excessive cloning that happens whenever the scroll position is updated. In order to get new left and top props, we have to clone the entire tree because it's immutable. It'd be much faster if we could update the left and top props of a ParallaxLayer in-place, instead of cloning it.

How can we do that while keeping this.props.children immutable? The answer is we can't!

In order to improve the animation performance, we'd have to make a compromise to the immutability rule. Framer X provides an Animatable that allows us to update its value in-place:

anAnimatable.set(newValue)

This is the same idea as Animated on React Native that provides high-performance animation on mobile platforms.

So, instead of plain numbers, we'll use Animatable for the left and top props of ParallaxLayer. We'd only need to clone the children tree once to replace the left and top props of all ParallaxLayers:

cloneAndOffsetParallaxLayers(child) {
  const getUpdatedPositionProps = (node) => {
    if (isParallaxLayer(node)) {
      ...
const left = Animatable(oldLeft);
const top = Animatable(oldTop);
// Keep a record of all layers so that
// we could update them later on scroll.
this.layerConfigs.push({left, top, props: node.props})
return {left, top}; } else { return null; } } return cloneAndUpdateProps(getUpdatedPositionProps, child) }

After that, when the scroll position updates, we can simply update the value of all the Animatables:

handleScroll = ({x, y}) => {
  this.layerConfigs.forEach(({left, top, props}) => {
    ...
    left.set(newLeft);
    top.set(newTop);
  })
}

This is definitely better than having to re-render and re-clone the entire children tree whenever the scroll position updates.

Oh btw, don't forget to use Frame instead of div in ParallaxLayer. Frame knows how to handle left and top props when they are Animatables whereas div does not.

import { Frame } from 'framer';
... class ParallaxLayer extends React.Component { ... render() { return ( <Frame {...this.props} background={null}> {this.props.children} </Frame> ); } }

Performance issue of cloning

Alright, we've significantly reduced the number of re-rendering and cloning. This is definitely an improvement.

However, although we are doing it less frequently now, if the children tree is very deep and complex, it'll take a while for the cloning process to finish. This might freeze the UI and cause a janky experience.

Even worse, Framer X will throw an error in our face if we are trying to clone a big tree:

exceeded time limit error

That's because Framer X is fairly strict about responsiveness. It only gives components 9ms each frame to finish the rendering.

We give every component 9ms per frame. For some operations, like mounting and rendering children for first time, that is not much. You can use a short timer to drive setState() and build up the component in some staggering way.

— onnlucky🍀 (@onnlucky) October 8, 2018

This is definitely a bummer. Fortunately, for the use case of Parallax, there's a workaround and we don't have to clone at all! I'll talk about the technique in the next section.

On another note, sometimes we have no choice but to clone. For example, in my other component, Translate All, I'd have to make changes to components I don't have much control. In order to prevent the "exceeded time limit" error, as onnlucky mentioned, I'd need to do it in a "staggered way" -- divide the cloning process into small pieces that fit the 9ms window of each frame.

It's possible to clone the children tree with a stack instead of recursion. With a stack, it'd be easier to save the unfinished work and pick up where it's left off on the next frame. This may be a little tricky to implement though. The good news is that the upcoming time slicing in React sounds like exactly what we wanted! Hopefully Framer X will update React soon enough when it's available.

Approach 3: No cloning at all with React Context

The code of this approach on this branch.

Why tree traversal if we have full control of ParallaxLayer?

The previous two approaches perform a full tree traversal to find out all ParallaxLayers and then clone the entire tree to make changes. This is where the slowness comes in -- if the tree has many nodes, it'd take quite a while to traverse and clone it.

How to avoid tree traversal while still getting hold of all ParallaxLayers?

If we look closely, ParallaxLayer is a component we write and we have full control of it. Instead of being passively picked out by the container, why can't a ParallaxLayer announce itself voluntarily?

Here's the solution. We can ask a ParallaxLayer to register itself when it's mounted, and unregister when it's unmounted. This way, we don't need to traverse the children tree at all!

class ParallaxLayer extends React.Component {
  ...
  componentDidMount() {
    // register
  }
  componentWillUnmount() {
    // unregister
  }
}

I'll talk about the details in a bit.

As a bonus, in approach 2 we had to clone the tree to change the left and top props of ParallaxLayer to Animatable, remember? In fact, since we have full control of the ParallaxLayer component, why can't we just use Animatables in the render method? No funky cloning at all!

class ParallaxLayer extends React.Component {
  ...
  layerConfig = {
    left: Animatable(0),
    top: Animatable(0),
  }
  render() {
    return (
      <Frame
        left={this.layerConfig.left}
        top={this.layerConfig.top}>
{this.props.children}
</Frame>
) }

Pass register functions downwards with React Context

Let's now study the implementation details of the register/unregister functions.

First of all, we'd save information of all the layers to the parent ParallaxScroll so that we could access it when the scroll position is updated. It could be a simple array available as a class field:

class ParallaxScroll extends React.Component {
  layerConfigs = []
  registerLayer = (layer) => {
    this.layerConfigs.push(layer)
  }
  ...
}

But how do we pass this registerLayer function to ParallaxScroll's children?

If the children of ParallaxScroll were hardcoded as below, it'd be fairly straightforward -- we can just pass the function as a prop:

<Scroll>
  <ParallaxLayer register={this.registerLayer} />
  <ParallaxLayer register={this.registerLayer} />
  ...
</Scroll>

Unfortunately that is not the case. The code is like this and the ParallaxLayers in the children are added dynamically:

<Scroll>{this.props.children}</Scroll>

In this case, we can use React Context to pass the register functions downwards.

class ParallaxScroll extends React.Component {
  render() {
    return (
      <RegisterContext.Provider
        value={{
          registerLayer: this.registerLayer,
          unregisterLayer: this.unregisterLayer,
        }}
      >
        <Scroll {...this.props} onMove={this.handleScroll}>
          {children}
        </Scroll>
      </RegisterContext.Provider>
    )
  }
}

In ParallaxLayer, we can get the register/unregister functions out of the context this way:

class ParallaxLayer extends React.Component {
  render() {
    return (
      <RegisterContext.Consumer>
        {({ registerLayer, unregisterLayer }) => {
          // pass registerLayer / unregisterLayer as props
          // to downstream components
        }}
      </RegisterContext.Consumer>
    )
  }
}

Conclusion

So that's it! In this post, we've looked at three approaches of how to find out all ParallaxLayers in the children tree and update their positions. The register/unregister approach with React Context is clearly the winner. It gets the job done without the overhead of cloning the tree.

We should think twice before cloning a children tree because it's potentially costly. Sometimes, we can ask the children to announce themselves voluntarily instead of being picked out passively with a tree traversal.


Visits: 0
Discuss on Twitter

I hope you find this article useful!

One of my 2021 goals is to write more posts that are useful, interactive and entertaining. Want to receive early previews of future posts? Sign up below. No spam, unsubscribe anytime.