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;
}
`;
Top comments (16)
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?
Yup exactly. The hook doesn't create an instance of the
Panel
component. The hook passes back a component definition and theprops
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 patternI think you could safely:
That creates a new
Panel
element, but doesn't redefine thePanel
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?yeah, I really want to know this too
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.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.
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.
I took the idea described above and tried to capture it in in a new blog post, Compound Components with a Hook
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!
Good idea and good article. Stumbled upon this while thinking about doing the same thing. Ended up implementing my own hook that returned a component. But then stumbled upon the React documentation for render props.
What do you think? Does this have any advantage over render props?
Fixes the concern of hooks being more logic focused and components containing view related stuff.
Render props definitely have their uses, but in this situation I was trying to account for a situation where you want to hoist the behavior of a component (the
Panel
's open and close methods) to the parent, without having to fully manage that state in the parent component.The issue with render props is that the "state" that is provided to the render function is not available to you in the parent component. In the example above, the Panel component could provide
openPanel
,closePanel
,isOpen
to a render function (or render as children or w/e). But then what if I want to have a button somewhere else on the page to trigger the panel opening? TheopenPanel
is only available inside the render prop function.Thank you for this article
I really like this way of using hooks
Cool concept - why doesn't the onRenderNavigation prop need to be memoized?
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.
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 :)