New React Hooks Pattern? Return a Component
Tags: react, webdev, javascript, typescript
Published: 2020-04-24 05:28:20

I recently listened to a [podcast](https://reactpodcast.simplecast.fm/85) 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:*

```jsx
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*

```jsx
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](https://developer.microsoft.com/en-us/fluentui#/controls).  Overall, I enjoy using the library. However, the [Panel](https://developer.microsoft.com/en-us/fluentui#/controls/web/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.
```jsx
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.


```jsx
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.


```jsx
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](https://www.reddit.com/r/reactjs/comments/9yq1l8/how_do_you_feel_about_a_hook_returning_components/?sort=new).

Here is the full implementation of the `usePanel` hook
```jsx
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;
  }
`;

```