Post contents
In our previous chapter, we built context menu functionality into our App
component. This functionality allowed us to right-click on an element and get a list of actions we could take.
This code works as we'd expect, but it doesn't follow a fundamental pattern of React, Angular, or Vue: It's not componentized.
Let's fix this by moving our context menu code into its own component. This way, we're able to do refactors more easily, code cleanup, and more.
- React
- Angular
- Vue
const ContextMenu = ({ isOpen, x, y, onClose }) => { const [contextMenu, setContextMenu] = useState(); useEffect(() => { if (!contextMenu) return; const closeIfOutsideOfContext = (e) => { const isClickInside = contextMenu.contains(e.target); if (isClickInside) return; onClose(false); }; document.addEventListener("click", closeIfOutsideOfContext); return () => document.removeEventListener("click", closeIfOutsideOfContext); }, [contextMenu]); if (!isOpen) return null; return ( <div ref={(el) => setContextMenu(el)} tabIndex={0} style={{ position: "fixed", top: y, left: x, background: "white", border: "1px solid black", borderRadius: 16, padding: "1rem", }} > <button onClick={() => onClose()}>X</button> This is a context menu </div> );};function App() { const [mouseBounds, setMouseBounds] = useState({ x: 0, y: 0, }); const [isOpen, setIsOpen] = useState(false); function onContextMenu(e) { e.preventDefault(); setIsOpen(true); setMouseBounds({ x: e.clientX, y: e.clientY, }); } return ( <> <div style={{ marginTop: "5rem", marginLeft: "5rem" }}> <div onContextMenu={onContextMenu}>Right click on me!</div> </div> <ContextMenu isOpen={isOpen} onClose={() => setIsOpen(false)} x={mouseBounds.x} y={mouseBounds.y} /> </> );}
You may have noticed that during this migration, we ended up removing a crucial accessibility feature: We're no longer running focus
on the context menu when it opens.
Why was it removed, and how can we add it back?
Introducing Component Reference
The reason we removed the context menu's focus management is to keep control of the context menu in the parent.
While we could have kept the .focus()
logic in the component using a component side effect handler, this muddies the water a bit. Ideally, in a framework, you want your parent to be in charge of the child component's behavior.
This allows you to re-use your context menu component in more places, should you theoretically ever want to use the component without forcing a focus change.
To do this, let's move the .focus
method out of our component. Moving from this:
/* This is valid JS, but is only pseudocode of what each framework is doing */// Child componentfunction onComponentRender() { document.addEventListener("click", closeIfOutsideOfContext); contextMenu.focus();}// Parent componentfunction openContextMenu(e) { e.preventDefault(); setOpen(true);}
To this:
/* This is valid JS, but is only pseudocode of what each framework is doing */// Child componentfunction onComponentRender() { document.addEventListener("click", closeIfOutsideOfContext);}// Parent componentfunction openContextMenu(e) { e.preventDefault(); setOpen(true); contextMenu.focus();}
While this might seem like a straightforward change at first, there's a new problem present: Our contextMenu
is now inside a component. As a result, we need to access not only the underlying DOM node using element reference but the ContextMenu
component instance as well.
Luckily for us, each framework enables us to do just that! Before we implement the focus
logic, let's dive into how component reference works:
- React
- Angular
- Vue
React has two APIs that help us gain insights into a component's internals from its parent:
Let's start with the basics: forwardRef
.
forwardRef
forwardRef
does what it says on the tin: It allows you to forward a ref
property through a component instance.
See, in React, ref
is a special property. This means that to be used properly, React has to have a special syntax to enable its expected functionality.
As a result, the following code does not work:
const Component = ({ ref, style }) => { return <div ref={ref} style={style} />;};const App = () => { return ( <Component ref={(el) => alert(el)} style={{ height: 100, width: 100, backgroundColor: "red" }} /> );};
Doing this will result in our ref
callback not being called as expected, alongside two error messages explaining why:
Warning: Component:
ref
is not a prop. Trying to access it will result inundefined
being returned. If you need to access the same value within the child component, you should pass it as a different prop. (https://react.dev/warnings/special-props)
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use
forwardRef()
?
To solve this, we have two options:
- Rename our
ref
property to another name, likedivRef
:
const Component = ({ divRef, style }) => { return <div ref={divRef} style={style} />;};const App = () => { return ( <Component divRef={(el) => alert(el)} style={{ height: 100, width: 100, backgroundColor: "red" }} /> );};
- Use the
forwardRef
API, as suggested by the error message originally printed.
import { forwardRef } from "react";const Component = forwardRef((props, ref) => { return <div ref={ref} style={props.style} />;});const App = () => { return ( <Component ref={(el) => alert(el)} style={{ height: 100, width: 100, backgroundColor: "red" }} /> );};
As we can see, forwardRef
accepts slightly modified component functions. While the first argument might look familiar as our place to access properties, our special property ref
is passed as a second argument.
We can then forward that ref
to wherever we want to gain access to an underlying DOM node in the child.
But what if we wanted more control over our child component? What if we wanted to access data and methods from the child component using a ref
?
Luckily, useImperativeHandle
does just that!
useImperativeHandle
While forwardRef
enables us to pass a ref
to a child component, useImperativeHandle
allows us to fully customize this ref
to our heart's content.
import { forwardRef, useImperativeHandle } from "react";const Component = forwardRef((props, ref) => { useImperativeHandle(ref, () => { // Anything returned here will be assigned to the forwarded `ref` return { pi: 3.14, sayHi() { alert("Hello, world"); }, }; }); return <div style={props.style} />;});const App = () => { return ( <Component ref={(el) => console.log(el)} style={{ height: 100, width: 100, backgroundColor: "red" }} /> );};
Here, we can assign properties, functions, or any other JavaScript values to the forwarded ref
. If we look at the output of our ref
callback from App
it shows up the object that we assigned using useImperativeHandle
:
({ pi: 3.14, sayHi: sayHi() });
That sayHi
function still works, too! If we change App
to the following:
const App = () => { const compRef = useRef(); return ( <> <button onClick={() => compRef.current.sayHi()}>Say hi</button> <Component ref={compRef} /> </> );};
It will output Hello, world
, just as we would expect it to!
useImperativeHandle
Dependency Array
Let's stop and think about how useImperativeHandle
works under the hood for a moment.
We know that useRef
creates an object with the shape of:
({ current: initialValue });
Which you can then mutate without triggering a re-render.
const App = () => { const numberOfRenders = useRef(0); numberOfRenders.current += 1; return null;};
Now, let's say that we didn't have access to useImperativeHandle
, but still wanted to pass a value from a child component to a parent via the passed ref
. That might look something like this:
const Child = forwardRef((props, ref) => { ref.current += 1; return null;});const Parent = () => { const numberOfChildRenders = useRef(0); return <Child ref={numberOfChildRenders} />;};
But wait a moment! If we think back to our Side Effects chapter, we'll remember that mutating state outside a component's local values is an example of a side effect.
Because this kind of in-render side effect mutation can cause strange issues and edge-cases with React, we need to make sure that we're using useEffect
or useLayoutEffect
.
Because our code ideally should happen during our render (rather than afterward), let's opt to use useLayoutEffect
.
Changing the previous code to use useLayoutEffect
looks something like this:
const Child = forwardRef((props, ref) => { useLayoutEffect(() => { ref.current += 1; }); return null;});
This is similar to how useImperativeHandle
works under the hood. It's so similar, in fact, that it's effectively how the hook is written in React's source code itself.
But wait!
useLayoutEffect
has the option to pass an array to it so that you can avoid re-running the side effect. Does that work inuseImperativeHandle
as well?
Indeed, it does, astute reader! Instead of:
useLayoutEffect(() => { ref.current = someVal;}, [someVal]);
We might do the following instead:
useImperativeHandle(ref, () => someVal, [someVal]);
Using Component Reference to Focus Our Context Menu
Now that we sufficiently understand what component references look like in each framework, let's add it to our App
component to re-enable focusing our ContextMenu
component when it opens.
Remember, if you see:
setTimeout(() => { doSomething();}, 0);
It means that we want to defer the
doSomething
call until after all other tasks are complete. We're using this in our code samples to say:"Wait until the element is rendered to run
.focus()
on it"
- React
- Angular
- Vue
const ContextMenu = forwardRef(({ isOpen, x, y, onClose }, ref) => { const [contextMenu, setContextMenu] = useState(); useImperativeHandle(ref, () => ({ focus: () => contextMenu && contextMenu.focus(), })); // ... return ( // Attributes removed for brevity <div ref={(el) => setContextMenu(el)}>{/* ... */}</div> );});function App() { const [mouseBounds, setMouseBounds] = useState({ x: 0, y: 0, }); // ... const [isOpen, setIsOpen] = useState(false); const contextMenuRef = useRef(); useEffect(() => { if (isOpen) { setTimeout(() => { if (!contextMenuRef.current) return; contextMenuRef.current.focus(); }, 0); } }, [isOpen, mouseBounds]); return ( <> {/* ... */} <ContextMenu ref={contextMenuRef} isOpen={isOpen} onClose={() => setIsOpen(false)} x={mouseBounds.x} y={mouseBounds.y} /> </> );}
Challenge
This information about component reference isn't just theoretically useful. You're able to apply it to your codebase to enable new methods of building out components.
Let's see that in action by building a sidebar component that's able to expand and collapse.
To add an extra special interaction with this sidebar, let's make it so that when the user shrinks their screen to a certain size, it will automatically collapse the sidebar.
To do this, we'll:
- Set up our
App
component to handle a left and main column. - Make a sidebar that can collapse and expand to grow and shrink the main column.
- Automatically expand or collapse the sidebar as the browser grows and shrinks.
Let's dive in.
Step 1: Setup App Component Layout
Let's start creating our sidebar!
Our first step in doing so will be creating a layout file that includes a left-hand sidebar and a main content area on the right side.
To do that might look something like this:
- React
- Angular
- Vue
const Layout = ({ sidebar, sidebarWidth, children }) => { return ( <div style={{ display: "flex", flexWrap: "nowrap", minHeight: "100vh" }}> <div style={{ width: `${sidebarWidth}px`, height: "100vh", overflowY: "scroll", borderRight: "2px solid #bfbfbf", }} > {sidebar} </div> <div style={{ width: "1px", flexGrow: 1 }}>{children}</div> </div> );};const App = () => { return ( <Layout sidebar={<p>Sidebar</p>} sidebarWidth={150}> <p style={{ padding: "1rem" }}>Hi there!</p> </Layout> );};
Step 2: Make a Collapsible Sidebar
Now that we have a rough sidebar, we'll make it so that the user can manually collapse the sidebar.
This can be done by having an isCollapsed
state that the user toggles with a button.
When isCollapsed
is true
, it will only show the toggle button, but when isCollapsed
is false
, it should display the full sidebar's contents.
We'll also set up constants to support different widths of this sidebar area if it's collapsed or not.
- React
- Angular
- Vue
const Sidebar = ({ toggle }) => { const [isCollapsed, setIsCollapsed] = useState(false); const setAndToggle = (v) => { setIsCollapsed(v); toggle(v); }; const toggleCollapsed = () => { setAndToggle(!isCollapsed); }; if (isCollapsed) { return <button onClick={toggleCollapsed}>Toggle</button>; } return ( <div> <button onClick={toggleCollapsed}>Toggle</button> <ul style={{ padding: "1rem" }}> <li>List item 1</li> <li>List item 2</li> <li>List item 3</li> <li>List item 4</li> <li>List item 5</li> <li>List item 6</li> </ul> </div> );};const collapsedWidth = 100;const expandedWidth = 150;const App = () => { const [width, setWidth] = useState(expandedWidth); return ( <Layout sidebarWidth={width} sidebar={ <Sidebar toggle={(isCollapsed) => { if (isCollapsed) { setWidth(collapsedWidth); return; } setWidth(expandedWidth); }} /> } > <p style={{ padding: "1rem" }}>Hi there!</p> </Layout> );};
Step 3: Auto-Collapse Sidebar on Small Screens
Finally, let's auto-collapse the sidebar on screens smaller than 600px wide.
We can do this using a side effect handler to add a listener for screen resizes.
Then, we'll use framework-specific code similar to the following pseudocode to expand or collapse the sidebar based on the screen size:
const onResize = () => { if (window.innerWidth < widthToCollapseAt) { sidebarRef.collapse(); } else if (sidebar.isCollapsed) { sidebarRef.expand(); }};window.addEventListener("resize", onResize);// Laterwindow.removeEventListener("resize", onResize);
Let's implement it:
- React
- Angular
- Vue
const Sidebar = forwardRef(({ toggle }, ref) => { const [isCollapsed, setIsCollapsed] = useState(false); const setAndToggle = (v) => { setIsCollapsed(v); toggle(v); }; useImperativeHandle( ref, () => ({ collapse: () => { setAndToggle(true); }, expand: () => { setAndToggle(false); }, isCollapsed: isCollapsed, }), [isCollapsed, setAndToggle], ); const toggleCollapsed = () => { setAndToggle(!isCollapsed); }; if (isCollapsed) { return <button onClick={toggleCollapsed}>Toggle</button>; } return ( <div> <button onClick={toggleCollapsed}>Toggle</button> <ul style={{ padding: "1rem" }}> <li>List item 1</li> <li>List item 2</li> <li>List item 3</li> <li>List item 4</li> <li>List item 5</li> <li>List item 6</li> </ul> </div> );});const collapsedWidth = 100;const expandedWidth = 150;const widthToCollapseAt = 600;const App = () => { const [width, setWidth] = useState(expandedWidth); const sidebarRef = useRef(); useEffect(() => { const onResize = () => { if (window.innerWidth < widthToCollapseAt) { sidebarRef.current.collapse(); } else if (sidebarRef.current.isCollapsed) { sidebarRef.current.expand(); } }; window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); }, [sidebarRef]); return ( <Layout sidebarWidth={width} sidebar={ <Sidebar ref={sidebarRef} toggle={(isCollapsed) => { if (isCollapsed) { setWidth(collapsedWidth); return; } setWidth(expandedWidth); }} /> } > <p style={{ padding: "1rem" }}>Hi there!</p> </Layout> );};
Final code output
Now, when the user makes their screen too small, the sidebar automatically collapses. This makes the rest of our app much easier to interact with on mobile devices.
Truth be told, this is not necessarily how I would build this component in production. Instead, I might "raise the state" of "collapsed" from the
Sidebar
component to theApp
component.This would give us greater flexibility in controlling our sidebar's
isCollapsed
state without having to use a component reference.However, if you're building a UI library meant to interact with multiple applications, sometimes having this state lowered can allow you to reduce boilerplate between apps that share this component.