New React Hooks Pattern? Return a Component
Andrew Petersen
Posted on April 24, 2020
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;
}
`;
Posted on April 24, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.