Shared Component Logic

January 6, 2025

3,613 words

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.

FrameworkMethod Of Logic Sharing
ReactCustom Hooks
AngularSignal Functions
VueCompositions

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:

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 or while 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.

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 or width 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.

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.

You right-click to open the context menu open. Then, when you left-click outside of the bounds of the context menu, it will close it

Let's break these components into smaller pieces that we'll create composable logic for:

  1. A listener for clicks outside the context menu
  2. 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.

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);
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)}				/>			)}		</>	);}
Previous articlePortals
Next article Directives

Subscribe to our newsletter!

Subscribe to our newsletter to get updates on new content we create, events we have coming up, and more! We'll make sure not to spam you and provide good insights to the content we have.