Post contents
Components are awesome. They allow you to make your code logic more modular and associate that logic with a related collection of DOM nodes. More importantly than that, components are composable; You can take two components and use them together to build a third that employs them both.
Sometimes, while building components, you may find yourself needing to share logic between multiple components.
We're not talking about sharing state or logic between instances of the same component:
IE: Two instances of the same component sharing the same data.
Instead, we're talking about a way to share logic for each instance of a component.
IE: Two instances of the same component have their own data.
For example, let's say that you have some component code that detects the current window size. While this might seem like a simple problem at first, it requires you to:
- Get the initial window size and share that data with the component
- Add and clean up event listeners for when the user resizes their browser window
- Compose the window sizing logic inside other shared logic, such as an
onlyShowOnMobile
boolean
The method of how this logic is shared between components differs from framework to framework.
Framework | Method Of Logic Sharing |
---|---|
React | Custom Hooks |
Angular | Signal Functions |
Vue | Compositions |
We'll spend the chapter talking about how to do all of this and see how we can apply these methods to production code.
But here's my favorite part about these methods: We don't have to introduce any new APIs to use them. Instead, we'll combine a culmination of other APIs we've learned to this point.
Without further ado, let's build the window size shared logic.
Sharing Data Storage Methods
The first step of creating composable shared logic is to create a way to store data in an instance of the logic:
- React
- Angular
- Vue
In a typical React component, we'd store data using the useState
or useReducer
hook. Using React's custom hooks, we'll use the same APIs to create our own hook that combines (or composes) these other APIs:
const useWindowSize = () => { const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); return { height, width };};
We can then use this useWindowSize
custom hook just as we would any other hook:
const App = () => { const { height, width } = useWindowSize(); return ( <p> The window is {height}px high and {width}px wide </p> );};
Rules of Custom Hooks
In our "Intro to Components" chapter, we covered the rules of React's built-in hooks.
We mentioned that there are a few rules for any hooks:
- Be called from a component* (no normal functions)
- Not be called conditionally inside a component (no
if
statements) - Not be called inside a loop (no
for
orwhile
loops)
While these rules are mostly true, let's expand the first point to include "other hooks" as a place you're allowed to call a hook from.
Now that we've corrected that, it's a good time to mention that custom hooks also follow these rules.
There's one additional rule that custom hooks must follow, and that's:
- Your custom Hook's name must start with
use
.
To recap, this means that the following custom hooks are not allowed:
// ❌ Not allowed, the function name must start with `use`const getWindowSize = () => { const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); return { height, width };};
const useWindowSize = () => { const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); return { height, width };};// ❌ Not allowed, you must use a hook _inside_ a component or another hookconst { height, width } = useWindowSize();const Component = () => { return <p>Height is: {height}</p>;};
const useWindowSize = () => { const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); return { height, width };};function getWindowSize() { // ❌ Not allowed, you cannot use a hook inside a non-hook function const { height, width } = useWindowSize(); return { height, width };}
const useWindowSize = () => { // ❌ Not allowed, you cannot `return` before using a hook if (bool) return { height: 0, width: 0 }; const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); return { height, width };};
Sharing Side Effect Handlers
While sharing data between a consuming component is helpful in its own right, this is only a fraction of the capabilities these frameworks have for cross-component logic reuse.
One of the most powerful things that can be reused between components is side effect logic.
Using this, we can say something along the lines of:
When a component that implements this shared bit of code renders, do this behavior.
And combine it with our data storage to say:
When the component renders, store some calculation and expose it back to the consuming component.
This can be a bit vague to discuss without code, so let's dive in.
While our last code sample was able to expose the browser window's height and width, it didn't respond to window resizing. This means that if you resized the browser window, the value of height
and width
would no longer be accurate.
Let's use the window listener side effect we built in our "Side Effects" chapter to add an event handler to listen for window resizing.
- React
- Angular
- Vue
const useWindowSize = () => { const [height, setHeight] = useState(window.innerHeight); const [width, setWidth] = useState(window.innerWidth); useEffect(() => { function onResize() { setHeight(window.innerHeight); setWidth(window.innerWidth); } window.addEventListener("resize", onResize); // Remember to cleanup the listener return () => window.removeEventListener("resize", onResize); }, []); return { height, width };};
... That's it!
There's nothing more we need to do inside our useWindowSize
consuming component; it simply works transparently as if we had placed the useEffect
in the component itself.
const App = () => { const { height, width } = useWindowSize(); return ( <p> The window is {height}px high and {width}px wide </p> );};
Notice that we've changed exactly zero lines of code from our previous example of this component! ✨ Magic ✨
Composing Custom Logic
We've covered how shared logic can access data storage and side-effect handlers. Now let's talk about the fun stuff: Composability.
Not only can you call your custom logic from components, but you can call them from other shared logic fragments.
For example, let's say that we want to take our window size getter and create another custom logic fragment that composes it.
If we were using plain-ole functions, it might look something like this:
function getWindowSize() { return { height: window.innerHeight, width: window.innerWidth, };}function isMobile() { const { height, width } = getWindowSize(); if (width <= 480) return true; else return false;}
But this comes with downsides when trying to include this logic in a framework, such as:
- No access to side effect cleanup
- No automatic-re-rendering when
height
orwidth
changes
Luckily for us, we can do this with our frameworks with full access to all the other framework-specific APIs we've covered until now.
- React
- Angular
- Vue
So, do you remember how we used useState
inside of useWindowSize
? That's because all hooks are composable.
This is true for custom hooks as well, meaning that we can do the following code:
const useMobileCheck = () => { const { width } = useWindowSize(); if (width <= 480) return { isMobile: true }; else return { isMobile: false };};
Without modifying the useWindowSize
component.
To consume our new useMobileCheck
component is just as straightforward as it was to use useWindowSize
:
const Component = () => { const { isMobile } = useMobileCheck(); return <p>Is this a mobile device? {isMobile ? "Yes" : "No"}</p>;};
Challenge
Let's take everything we've learned about shared-component logic and use it to recreate our ContextMenu
component from the "Component Reference" chapter in smaller pieces.
Let's break these components into smaller pieces that we'll create composable logic for:
- A listener for clicks outside the context menu
- A composition that gets the bounds of the context menu's parent element
Step 1: Create an Outside Click Composition
To listen for clicks outside the context menu, we can leverage some JavaScript akin to the following:
const closeIfOutsideOfContext = (e) => { const isClickInside = ref.value.contains(e.target); if (isClickInside) return; closeContextMenu();};document.addEventListener("click", closeIfOutsideOfContext);
Let's turn this into a composition that we can use in our ContextMenu
component.
- React
- Angular
- Vue
const useOutsideClick = ({ ref, onClose }) => { useEffect(() => { const closeIfOutsideOfContext = (e) => { const isClickInside = ref.current.contains(e.target); if (isClickInside) return; onClose(); }; document.addEventListener("click", closeIfOutsideOfContext); return () => document.removeEventListener("click", closeIfOutsideOfContext); }, [onClose]);};
Now that we have our useOutsideClick
hook written, we can use it in our ContextMenu
component:
const ContextMenu = forwardRef(({ x, y, onClose }, ref) => { const divRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => divRef.current && divRef.current.focus(), })); useOutsideClick({ ref: divRef, onClose }); return ( <div tabIndex={0} ref={divRef} style={{ position: "fixed", top: y + 20, left: x + 20, background: "white", border: "1px solid black", borderRadius: 16, padding: "1rem", }} > <button onClick={() => onClose()}>X</button> This is a context menu </div> );});
Step 2: Create a Bounds Composable
Now let's move the bounds' size checking into a composable as well. This is done in JavaScript like so:
const resizeListener = () => { if (!el) return; const localBounds = el.getBoundingClientRect(); setBounds(localBounds);};resizeListener();window.addEventListener("resize", resizeListener);window.removeEventListener("resize", resizeListener);
- React
- Angular
- Vue
const useBounds = () => { const [bounds, setBounds] = useState({ height: 0, width: 0, x: 0, y: 0, }); const [el, setEl] = useState(null); const ref = useCallback((el) => { setEl(el); }, []); useEffect(() => { const resizeListener = () => { if (!el) return; const localBounds = el.getBoundingClientRect(); setBounds(localBounds); }; resizeListener(); window.addEventListener("resize", resizeListener); window.removeEventListener("resize", resizeListener); }, [el]); return { ref, bounds };};
Now we wrap this into our App
root:
function App() { const { ref, bounds } = useBounds(); // An addEventListener is easier to tackle when inside the conditional render // Add that as an exploration for `useImperativeHandle` const [isOpen, setIsOpen] = useState(false); function onContextMenu(e) { e.preventDefault(); setIsOpen(true); } const contextMenuRef = useRef(); useEffect(() => { if (isOpen && contextMenuRef.current) { contextMenuRef.current.focus(); } }, [isOpen]); return ( <> <div style={{ marginTop: "5rem", marginLeft: "5rem" }}> <div ref={ref} onContextMenu={onContextMenu}> Right click on me! </div> </div> {isOpen && ( <ContextMenu x={bounds.x} y={bounds.y} ref={contextMenuRef} onClose={() => setIsOpen(false)} /> )} </> );}