Reusing React Component With React Router

Reusing React Component With React Router

·

6 min read

The Issue

Consider this case. I am building a spa with multiple page. There is a video in page A and also the same video in page B. I want to preserve the video playing state even if when user navigate from page A to page B.

Suppose we have this Video component.

const Video = () => {
  return (
    <video
      src="https://yaox023.com/static/big_buck_bunny_720p_surround.mp4"
      controls
    />
  );
};

If we put this component in different pages, then the component will be unmounted and mounted in the process of page navigation. We would not be able to preserve the video element state, such as playing state.

Video element is just an example. Some other elements may causes the same issue. For example, if we have a canvas and we are drawing something on it. This drawing process will be interrupted if we the page navigation happens.

As you can see, to solve the issue, we need to pull the component out of the scope of the react component lifecycle. Or at least the lifecycle of the pages. We can refactor our pages and pull the Video component up to the parent. This should work. But today I want to use another tool called react-reverse-portal to fix this issue.

react-reverse-portal

Accoding to its doc, it works because:

Added in React 16.0, React's built-in portals let you render an element in a meaningful location within your React component hierarchy, but then send the output to a DOM node elsewhere.

Reverse portals let you do the opposite: pull a rendered element from elsewhere into a target location within your React tree. This allows you to reparent DOM nodes, so you can move React-rendered elements around your React tree and the DOM without re-rendering them.

Let see how to use it.

First is to create a node, the component will be rendered inside this node.

const portalNode = React.useMemo(() => portals.createHtmlPortalNode(), []);

Then specify the target component inside InPortal.

<portals.InPortal node={portalNode}>
  <Video />
</portals.InPortal>

And lastly, pull the component from InPortal by OutPortal.

{componentToShow === "aaa" ? (
  <div>
    <h2>aaa</h2>
    <portals.OutPortal node={portalNode} />
  </div>
) : (
  <div>
    <h2>bbb</h2>
    <portals.OutPortal node={portalNode} />
  </div>
)}

The full demo code is like this:

import * as portals from "react-reverse-portal";
import React, { useState } from "react";

const Video = () => {
  return (
    <video
      src="https://yaox023.com/static/big_buck_bunny_720p_surround.mp4"
      controls
    />
  );
};

export default function Demo1() {
  const portalNode = React.useMemo(() => portals.createHtmlPortalNode(), []);

  const [componentToShow, setComponentToShow] = useState("aaa");

  const onClick = () => {
    console.log(componentToShow);
    if (componentToShow === "aaa") {
      setComponentToShow("bbb");
    } else {
      setComponentToShow("aaa");
    }
  };

  return (
    <div>
      <button onClick={onClick}>toggle</button>
      <portals.InPortal node={portalNode}>
        <Video />
      </portals.InPortal>

      {componentToShow === "aaa" ? (
        <div>
          <h2>aaa</h2>
          <portals.OutPortal node={portalNode} />
        </div>
      ) : (
        <div>
          <h2>bbb</h2>
          <portals.OutPortal node={portalNode} />
        </div>
      )}
    </div>
  );
}

With React Router

The above example is just a demo usage. Now let's see the issue in real: rendering the component in different pages.

Say we have routes like this:

const router = createBrowserRouter([
  {
    path: "/",
    element: <Root />,
    children: [
      {
        path: "aaa",
        element: <ComponentA />,
      },
      {
        path: "bbb",
        element: <ComponentB />,
      },
    ],
  },
]);

Then we render the InPortal in the Root component.

const portalNode = portals.createHtmlPortalNode();

export function Root() {
  return (
    <div>
      <h2>root</h2>
      <ul>
        <li>
          <Link to={"aaa"}>to page video big</Link>
        </li>
        <li>
          <Link to={"bbb"}>to page video small</Link>
        </li>
      </ul>
      <portals.InPortal node={portalNode}>
        <Video width={300} />
      </portals.InPortal>
      <Outlet />
    </div>
  );
}

And use OutPortal to render the component.

export function ComponentA() {
  return (
    <div style={{ height: "500px" }}>
      <h3>Big</h3>
      <portals.OutPortal node={portalNode} width={600} />
    </div>
  );
}

export function ComponentB() {
  return (
    <div style={{ height: "500px" }}>
      <h3>Small</h3>
      <portals.OutPortal node={portalNode} width={250} />
    </div>
  );
}

That's all we need. Now the video playing state will be preserved even if we are navigating to different pages.

How it works

Let's see how it works by checking the source code.

1. createPortalNode

This is the method just to create a dom node and return it with a few methods.

const createPortalNode = <C extends Component<any>>(
  elementType: ANY_ELEMENT_TYPE,
  options?: Options
): AnyPortalNode<C> => {
  // ...

  let element;
  if (elementType === ELEMENT_TYPE_HTML) {
    element = document.createElement("div");
  } else if (elementType === ELEMENT_TYPE_SVG) {
    element = document.createElementNS(SVG_NAMESPACE, "g");
  } else {
    throw new Error(
      `Invalid element type "${elementType}" for createPortalNode: must be "html" or "svg".`
    );
  }

  // ...

  const portalNode: AnyPortalNode<C> = {
    element,
    elementType,
    setPortalProps: (props: ComponentProps<C>) => {
    },
    getInitialPortalProps: () => {
    },
    mount: (newParent: HTMLElement, newPlaceholder: HTMLElement) => {
    },
    unmount: (expectedPlaceholder?: Node) => {
    },
  } as AnyPortalNode<C>;

  return portalNode;
};

2. InPortal

Use createPortal to render children under node.element which is the dom element created in the createPortalNode method.

class InPortal extends React.PureComponent<InPortalProps, { nodeProps: {} }> {
  constructor(props: InPortalProps) {
    // ...
  }

  // ...

  render() {
    const { children, node } = this.props;

    return ReactDOM.createPortal(
      React.Children.map(children, (child) => {
        if (!React.isValidElement(child)) return child;
        return React.cloneElement(child, this.state.nodeProps);
      }),
      node.element
    );
  }
}

3. OutPortal

A few notes:

  1. Render a `div`` as a placeholder
  2. Call mount method in after component mounts
  3. Call unmount and mount after component updates for handling switching OutPortal
class OutPortal<C extends Component<any>> extends React.PureComponent<
  OutPortalProps<C>
> {
  private placeholderNode = React.createRef<HTMLDivElement>();
  private currentPortalNode?: AnyPortalNode<C>;

  constructor(props: OutPortalProps<C>) {
    super(props);
    this.passPropsThroughPortal();
  }

  componentDidMount() {
    const node = this.props.node as AnyPortalNode<C>;
    this.currentPortalNode = node;

    const placeholder = this.placeholderNode.current!;
    const parent = placeholder.parentNode!;
    node.mount(parent, placeholder);
  }

  componentDidUpdate() {
    // We re-mount on update, just in case we were unmounted (e.g. by
    // a second OutPortal, which has now been removed)
    const node = this.props.node as AnyPortalNode<C>;

    // If we're switching portal nodes, we need to clean up the current one first.
    if (this.currentPortalNode && node !== this.currentPortalNode) {
      this.currentPortalNode.unmount(this.placeholderNode.current!);
      this.currentPortalNode = node;
    }

    const placeholder = this.placeholderNode.current!;
    const parent = placeholder.parentNode!;
    node.mount(parent, placeholder);
  }

  componentWillUnmount() {
    const node = this.props.node as AnyPortalNode<C>;
    node.unmount(this.placeholderNode.current!);
  }

  render() {
    return <div ref={this.placeholderNode} />;
  }
}

4. mount and unmount

Now let's how mount and unmount method works.

const portalNode = {
  // ...

  mount: (newParent: HTMLElement, newPlaceholder: HTMLElement) => {
    if (newPlaceholder === lastPlaceholder) {
      // Already mounted - noop.
      return;
    }
    portalNode.unmount();

    // To support SVG and other non-html elements, the portalNode's elementType needs to match
    // the elementType it's being rendered into
    if (newParent !== parent) {
      if (!validateElementType(newParent, elementType)) {
        throw new Error(
          `Invalid element type for portal: "${elementType}" portalNodes must be used with ${elementType} elements, but OutPortal is within <${newParent.tagName}>.`
        );
      }
    }

    newParent.replaceChild(portalNode.element, newPlaceholder);

    parent = newParent;
    lastPlaceholder = newPlaceholder;
  },

  // ...
};

As you can see, the key is in here newParent.replaceChild(portalNode.element, newPlaceholder);. This means when we do mounting in OutPortal, we are actually replace the placeholder with the element which is already rendering with InPortal.

const portalNode = {
  // ...

  unmount: (expectedPlaceholder?: Node) => {
    if (expectedPlaceholder && expectedPlaceholder !== lastPlaceholder) {
      // Skip unmounts for placeholders that aren't currently mounted
      // They will have been automatically unmounted already by a subsequent mount()
      return;
    }

    if (parent && lastPlaceholder) {
      parent.replaceChild(lastPlaceholder, portalNode.element);

      parent = undefined;
      lastPlaceholder = undefined;
    }
  },
};

Remember it is the time that when OutPortal unmounts, it calls the unmount method here. In here, it uses the replaceChild method again to move the target component back to the InPortal element.

The End

So, just as we said at the start, the key to solve this issue is to move the component from the react lifecycle. So with the react-reverse-portal tool, we only render the component once with createPortal, then use DOM api to move the component to the places we need. Because we will not rerender the component in navigation, so the react component states and the also the states in the element are being preserved.