DEV Community

Andrew Petersen
Andrew Petersen

Posted on

New React Hooks Pattern? Return a Component

I recently listened to a podcast where the creator of React Router, Michael Jackson mentioned a new pattern with hooks, returning a component.

At first, I couldn't grasp how this would differ from just calling a render function or another React component, and it seemed to go against the whole "Components for UI, hooks for behavior" mantra. But I think I've stumbled on a use case.

By the end of the article, I'll explain how I landed on this:

function ThingWithPanel() {

  let { Panel, panelProps, isOpen, openPanel, closePanel } = usePanel();

  return (
    <div>

      {!isOpen && <button onClick={openPanel}>Open Panel</button>}
      {isOpen && <button onClick={closePanel}>Close Panel</button>}

      <Panel {...panelProps}>
         <div>I am stuff in a panel</div>
      </Panel>

    </div>
  );
};

Instead of this

import { Panel } from "office-ui-fabric-react/lib/Panel";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import styled from "styled-components";

function ThingWithPanel() {
  let [isOpen, setIsOpen] = useState(startOpen);
  let openPanel = useCallback(() => {
    setIsOpen(true);
  }, [setIsOpen]);

  let closePanel = useCallback(() => {
    setIsOpen(false);
  }, [setIsOpen]);

  // If dealing with IFrames in the Panel,
  // usually want to wire up a way for the Iframed page
  // to tell the Parent to close the panel
  useEffect(() => {
    let handler = (event) => {
      try {
        let msg = JSON.parse(event.data);
        if (msg.type === "CLOSE_PANEL") {
          closePanel();
        }
      } catch (err) {
        // Couldn't parse json
      }
    };
    window.addEventListener("message", handler, false);
    return () => {
      window.removeEventListener("message", handler);
    };
  });

  return (
    <div>
      {!isOpen && <button onClick={openPanel}>Open Panel</button>}
      {isOpen && <button onClick={closePanel}>Close Panel</button>}

      <Panel
        isOpen={isOpen}
        isLightDismiss={true}
        onDismiss={closePanel}
        {/* Override the default Panel Header */}
        onRenderNavigation={() => (
          <StyledClose>
            <IconButton iconProps={{ iconName: "ChromeClose" }} onClick={closePanel} />
          </StyledClose>
        )}
      >
        <div>I am stuff in a panel</div>
      </Panel>
    </div>
  );
}

const StyledClose = styled.div`
  position: absolute;
  top: 5px;
  right: 23px;
  z-index: 10;
  background: #ffffffbf;
  border-radius: 50%;
  opacity: 0.85;
  &:hover {
    opacity: 1;
  }
`;

Pain points working with Component Libraries

At work, I frequently leverage Microsoft's version of Material UI, Fluent UI. Overall, I enjoy using the library. However, the Panel component causes me a few pain points:

  • I always have to setup the useState to track whether the panel is open, then use that to create functions to open and close the Panel.
  • I have to remember the prop, isLightDismiss, that says "close this panel when the user clicks off the panel". It's off by default and I almost always turn it on.
  • The default Panel Header renders with a bunch reserved whitespace so the Panel Content has a weird looking top margin.
    • So I override the header to absolute position it so my content shifts to the top of the panel
    • Because I override the header, I am responsible for rendering my own Close button in the top right.
  • If the Panel is rendering an IFrame, I usually wire up a PostMessage listener so the IFramed page can tell the parent window to close the panel.

The longer code snippet above implements these details.

It's not THAT big of a deal, but it's annoying to think about all that boilerplate for every instance of a Panel. It's easy to screw up, and adds unnecessary friction.

BTW, I'm not knocking UI Fabric. Component Libraries have to optimize for flexibility and reuse, not for my application's specific preferences.

Hooks to the Rescue

In most cases I would encapsulate my preferences by baking them into a wrapper component. But the Panel is more complicated because isOpen,openPanel, and closePanel can't be baked in because the parent needs to use them to control when the Panel is open.

*Here baked a lot of stuff into MyPanel, but we still have to manage the isOpen state outside the MyPanel component.

import { MyPanel } from "./MyPanel";

function ThingWithPanel() {

  // Setup the isOpen boilerplate
  let [isOpen, setIsOpen] = useState(startOpen);

  let openPanel = useCallback(() => {
    setIsOpen(true);
  }, [setIsOpen]);

  let closePanel = useCallback(() => {
    setIsOpen(false);
  }, [setIsOpen]);

  return (
    <div>
      {!isOpen && <button onClick={openPanel}>Open Panel</button>}
      {isOpen && <button onClick={closePanel}>Close Panel</button>}

      {/* Use the custom MyPanel component */}
      <MyPanel isOpen={isOpen} onDismiss={closePanel}>
        <div>I am stuff in a panel</div>
      </MyPanel>
    </div>
  );
}

Refactoring, we could create a custom hook to handle the isOpen boilerplate.

import { MyPanel, usePanel } from "./MyPanel";

function ThingWithPanel() {
  // Use the custom hook to control the panel state
  let { isOpen, openPanel, closePanel } = usePanel();

  return (
    <div>
      {!isOpen && <button onClick={openPanel}>Open Panel</button>}
      {isOpen && <button onClick={closePanel}>Close Panel</button>}

      {/* Use the custom MyPanel component */}
      <MyPanel isOpen={isOpen} onDismiss={closePanel}>
        <div>I am stuff in a panel</div>
      </MyPanel>
    </div>
  );
}

This solution is close, but something still feels off.

What if the hook took care of providing all the Panel Props?

  • Then we can just spread those props on the Panel component and not force everyone to memorize the UI Fabric API.

What if the hook also returns the Panel component?

  • Then consumers don't need to worry about the import
  • We'd have the flexibility to choose to provide the default Fabric Panel or provide our own custom MyPanel component. All without affecting the hook's consumers.
function ThingWithPanel() {

  let { Panel, panelProps, isOpen, openPanel, closePanel } = usePanel();

  return (
    <div>

      {!isOpen && <button onClick={openPanel}>Open Panel</button>}
      {isOpen && <button onClick={closePanel}>Close Panel</button>}

      <Panel {...panelProps}>
         <div>I am stuff in a panel</div>
      </Panel>

    </div>
  );
};

That feels clean! All the boilerplate has been removed without sacrificing any flexibility.

One important thing to note. Though the hook is returning a Component, it is really just syntax sugar. The hook is NOT creating a new Component definition each time the hook function executes. This would cause the React reconciler to see everything as a new Component; state would be reset every time. Dan Abramov discusses the issue on this Reddit post.

Here is the full implementation of the usePanel hook

import React, { useState, useCallback, useEffect } from "react";
import styled from "styled-components";
import { IconButton } from "office-ui-fabric-react/lib/Button";
import { PanelType, Panel as FabricPanel, IPanelProps } from "office-ui-fabric-react/lib/Panel";
import IFramePanel from "./IFramePanel";

export type PanelSize = "small" | "medium" | "large" | number;

export interface PanelOptions {
  /** Defaults to false. Should the panel be open by default? */
  startOpen?: boolean;
  /** The size of the panel. "small", "medium", "large", or a Number */
  size?: PanelSize;
}
let defaults: PanelOptions = {
  startOpen: false,
  size: "medium",
};

export function usePanel(opts: PanelOptions = {}) {
  let { startOpen, size } = { ...defaults, ...opts };
  let [isOpen, setIsOpen] = useState(startOpen);
  let openPanel = useCallback(() => {
    setIsOpen(true);
  }, [setIsOpen]);

  let closePanel = useCallback(() => {
    setIsOpen(false);
  }, [setIsOpen]);

  useEffect(() => listenForPanelClose(closePanel));

  let panelProps = {
    isOpen,
    onDismiss: closePanel,
    isLightDismiss: true,
    type: getPanelType(size),
    customWidth: typeof size === "number" ? size + "px" : undefined,
    onRenderNavigation: () => (
      <StyledClose>
        <IconButton iconProps={{ iconName: "ChromeClose" }} onClick={closePanel} />
      </StyledClose>
    ),
  };

  return {
    isOpen,
    openPanel,
    closePanel,
    panelProps,
    Panel,
  } as UsePanelResult;
}

export interface PanelProps extends IPanelProps {
  url?: string;
}

export const Panel: React.FC<PanelProps> = function ({ url, ...panelProps }) {
  if (url) return <IFramePanel url={url} {...panelProps} />;

  return <FabricPanel {...panelProps} />;
};
export interface UsePanelResult {
  /** Whether the panel is currently open */
  isOpen: boolean;
  /** A function you can call to open the panel */
  openPanel: () => void;
  /** A function you can call to close the panel */
  closePanel: () => void;
  /** The props you should spread onto the Panel component */
  panelProps: IPanelProps;
  /** The hook returns the UI Fabric Panel component as a nicety so you don't have to mess with importing it */
  Panel?: any;
}

const getPanelType = (size) => {
  if (size === "small") {
    return PanelType.smallFixedFar;
  }
  if (size === "medium") {
    return PanelType.medium;
  }
  if (size === "large") {
    return PanelType.large;
  }
  if (typeof size !== "string") {
    return PanelType.custom;
  }
  return PanelType.medium;
};
const CLOSE_MSG_TYPE = "CLOSE_PANEL";

// The parent window should create a panel then wire up this function
// to listen for anyone inside the IFrame trying to close the panel;
export const listenForPanelClose = function (cb: () => void) {
  let handler = (event) => {
    try {
      let msg = JSON.parse(event.data);
      if (msg.type === CLOSE_MSG_TYPE) {
        cb();
      }
    } catch (err) {
      // Couldn't parse json
    }
  };
  window.addEventListener("message", handler, false);
  return () => {
    window.removeEventListener("message", handler);
  };
};

export const triggerPanelClose = function () {
  let msg = JSON.stringify({
    type: CLOSE_MSG_TYPE,
  });
  window.top.postMessage(msg, "*");
};

const StyledClose = styled.div`
  position: absolute;
  top: 5px;
  right: 23px;
  z-index: 10;
  background: #ffffffbf;
  border-radius: 50%;
  opacity: 0.85;
  &:hover {
    opacity: 1;
  }
`;

Discussion (14)

Collapse
hummingbird24 profile image
HummingBird24

Hi Andrew, Nice writeup. Question: How is the hook not creating a new Component each time the hook function executes exactly? Is it because the Panel component is created outside of the hook function itself, unlike the OP's codesandbox of the reddit link you shared?

Collapse
droopytersen profile image
Andrew Petersen Author

Yup exactly. The hook doesn't create an instance of the Panel component. The hook passes back a component definition and the props that should be passed to that component when an instance is created.

There isn't a ton of value in sending back the component definition, but it eliminates an import and I like that it takes the guess work out of which component I should use. It feels kind of like the Compound Component pattern

Collapse
gillibrand profile image
Jay Gillibrand

I think you could safely:

return {
  Panel: <Panel {...panelProps}/>,
  // others
}
Enter fullscreen mode Exit fullscreen mode

That creates a new Panel element, but doesn't redefine the Panel function (the component definition). The problem in the linked Reddit post is that defines its component, Modal, in the hook itself.

If you did this, your hook would need to accept any children of the panel, but wouldn't need to export panelProps. So it may or may not be syntactic a win. But it should perform the same, right?

Collapse
stoic25 profile image
Lambert M.

yeah, I really want to know this too

Collapse
devinrhode2 profile image
Devin Rhode

I guess doing <Panel {...panelProps} or {renderPanel({ props... is the way to go. One major drawback with returning a variety of components from a hook, is that you lose out on tree shaking.

Collapse
phelpa profile image
Plínio Helpa

I just used your article to create a hook that returns a textEditor (the JSX) and the functions that my component needs.

My line of thought was : If the JSX is tied to the the functions, it makes more sense to use a hook that gives everything instead of importing the JSX separately.

Collapse
droopytersen profile image
Andrew Petersen Author • Edited on

Yeah that's where I landed too. If the hook is coupled to the UI any way, why not? One thing I've been noodling on lately is flipping it. Where component has the hook tacked on it.

import Modal from "./Modal"
// then use the hook like 
let modal = Modal.useModal({ ...stuff } )

// and use the component like 
<Modal {...modal.modalProps }>
  ....
</Modal>
Enter fullscreen mode Exit fullscreen mode
Collapse
droopytersen profile image
Andrew Petersen Author

I took the idea described above and tried to capture it in in a new blog post, Compound Components with a Hook

Collapse
timminata profile image
Timothy Trewartha

This was a great read and helped me with an issue I was facing. That said, splitting the component out from the body of the hook is a lot more hassle in my case and leads to some ugly code. I would love the simplicity of having it in the function body without the performance hit of it recreating every time. Thanks for the article and the link to reddit!

Collapse
terpimost profile image
Vlad Korobov

Thank you for this article

Collapse
seanmclem profile image
Seanmclem

I really like this way of using hooks

Collapse
limdev profile image
lim-dev

Cool concept - why doesn't the onRenderNavigation prop need to be memoized?

Collapse
droopytersen profile image
Andrew Petersen Author

You could. But that is used to render what is basically a leaf node component that is very small. So the cost of memoizing and checking for changes could be higher than letting the react reconciler diff the vdom.

Collapse
nicoguyon profile image
Nicolas Guyon

Thank you for this article.

That was exactly what I was thinking of and wondering if it was possible or eventually a bad idea. I intend to remove a lot of boilerplate now :)