Building External and Internal Next.js Routing Link Components For Material UI
Material-UI is an excellent UI library of React Components following the Material Design spec. Even if you don't adhere to the Material Design spec, Material-UI is super customizable for your specific needs.
A few of the great features of Material-UI include:
- The ability for Buttons, etc to easily switch between using html anchor elements and html buttons for better semantic markup without having separate components. See
component
in Button API - The ability for Buttons, etc to use custom components to enable 3rd party routing, etc. See Composition with 3rd Party Library
The combination of the above features gets a bit murky when you are trying to leverage TypeScript. Since it took me a few hours and I couldn't find any definitive examples easily, I thought I'd share my approach.
Goals
- Create an ExternalLink and InternalLink component that can be used with various Material-UI components such as Button.
- The InternalLink component should wrap the Next.js Link component enabling routing.
- Support all the normal HTML attributes for anchors including
target
,rel
,title
, etc as well as data attributes. - Implement
onClick
events for both components to allow for analytic event tracking, closing menus, etc. - Allow children of either component to be strings or other components
- No TypeScript compiler errors or warnings in strict mode.
ExternalLink
I started here thinking it would be "easy" to wrap a simple HTML anchor tag. However, it was a bit of a challenge to figure out the types. Here is the component I settled on.
import React from 'react';
function clickHandler(e: React.MouseEvent): void {
// Add your custom event handler code here - record analytics, etc
console.log('ExternalLink.clickHandler:', e);
}
interface ExternalLinkProps extends React.HTMLProps<HTMLAnchorElement> {
href: string; // Forces href to be required - see "Gotchas"
children: React.ReactNode; // Forces children to be required
}
// eslint-disable-next-line react/display-name
const ExternalLink = React.forwardRef<HTMLAnchorElement, ExternalLinkProps>((props, ref) => {
// Peel off the onClick handler if given
const { onClick, ...rest } = props;
const wrappedOnClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void => {
if (onClick) {
// If the consumer passed in onClick, call it
onClick(e);
}
// Finally, call our External Handler
clickHandler(e);
};
return <a ref={ref} {...rest} onClick={wrappedOnClick} />;
});
export default ExternalLink;
The key points here:
- Material-UI requires a component that can accept a
ref
. As such, we have to wrap our component in aReact.forwardRef
and pass it to the anchor. - Our type definition
ExternalLinkProps
extendsReact.HTMLProps<HTMLAnchorElement>
which allows for all the HTML anchor attributes. However, I am enforcing thehref
andchildren
to be required. See Gotchas below for more details. - We intercept the
onClick
so we can wrap it if given so we can extend click handler functionality. - All other props are forwarded down to the HTML anchor
InternalLink
The key difference with this component is that I needed to support Next routing. The Next Link
component takes in a series of props and binds to its children to enable routing. As such, it has to wrap something.
import React, { ReactNode, useContext } from 'react';
import NextLink from 'next/link';
interface InternalLinkProps extends React.HTMLProps<HTMLAnchorElement> {
href: string; // Forces href to be required - see "Gotchas"
as?: string; // Optional Next.js property to support dynamic routing
children: ReactNode; //
}
// eslint-disable-next-line react/display-name
const InternalLink = React.forwardRef<HTMLAnchorElement, InternalLinkProps>((props, ref) => {
// Peel off the onClick handler if given and the next props...
const { href, as, onClick, ...rest } = props;
// I defined the handler in the component here because I need access to react context ala useContext ... not demonstrated here
const wrappedOnClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void => {
if (onClick) {
// If the consumer passed in onClick, call it
onClick(e);
}
// Record analytics, close menus, etc
};
return (
<NextLink {...{ href, as }}>
<a ref={ref} {...rest} onClick={wrappedOnClick} />
</NextLink>
);
});
export default InternalLink;
The key points here:
- Very similar signature to ExternalLink above with the same features.
- Wraps the Next Link component thus enabling routing
Examples
// TS error - no href
<ExternalLink>Lorem</ExternalLink>
<InternalLink>Lorem</InternalLink>
// TS error - no children
<ExternalLink href="https://www.hashnode.com" />
InternalLink href="https://www.hashnode.com" />
// Success: Text Children
<ExternalLink href="https://www.hashnode.com">Lorem ipsum</ExternalLink>
<InternalLink href="/contact">Lorem ipsum</InternalLink>
// Success - other components
<ExternalLink href="https://www.hashnode.com"><span>Lorem ipsum</span</ExternalLink>
<InternalLink href="/contact"><span>Lorem ipsum</span></InternalLink>
// Success - other React components
<ExternalLink href="https://www.hashnode.com"><MailIcon ... /></ExternalLink>
<InternalLink href="/contact" as="/contact"><MailIcon /></InternalLink>
// Success - custom onClick gets wrapped correctly
<ExternalLink href="https://www.hashnode.com" onClick={(): void => { console.log('inline onClick called'); }}>Lorem</ExternalLink>
<InternalLink href="/contact" onClick={(): void => { console.log('inline onClick called'); }}>Lorem</InternalLink>
// Success - junk drawer of html attributes
<ExternalLink href="https://www.hashnode.com" title="Lorem" target="_blank" rel="noopener" id="mylink">Lorem ipsum</ExternalLink>
<InternalLink href="/contact" title="Lorem" target="_blank" rel="noopener" id="mylink2">Lorem ipsum</InternalLink>
// Success Using with Material-Ui button
<Button variant="outlined" color="primary" id="b1" href="https://hashnode.com" target="_blank" component={ExternalLink}>
External Link
</Button>
<Button variant="outlined" color="secondary" id="b2" href="/contact" component={InternalLink}>
Internal Link
</Button>
Here is a screenshot of the final two examples:
... and the rendered markup.
It feels like we have a fairly complete pair of interchangeable internal and external link components that we can easily pass either to Material-UI components and achieved our other goals. Success!
Gotchas
I'm learning TypeScript so these things were a source of confusion:
React.HTMLAttributes vs. React.HTMLProps
When defining the props interface, extend React.HTMLProps<HTMLAnchorElement>
rather than React.HTMLAttributes<HTMLAnchorElement>
. The later doesn't allow the href
attribute. However, the former makes the href
optional (presumably for internal page anchors) and so I added it to my type definition anyway, making the whole gotcha moot.
Event Types
React wraps the native browser events in Synthentic Event. As such, the type definition isn't the normal function definition type. React further defines these types such that our onClick
has a type of MouseEvent. This got even weirder as I was passing around the setState function from React's useState
hook which has a type of React.Dispatch<React.SetStateAction>
but I'll discuss that further in an different article.
Extending Next's Link props
While Next 9 is written in TypeScript, I had a heck of a time attempting to extend the LinkProps. Specifically, href
and as
are Url
types and conflict with the React HTML types. I gave up due to time, but I'd like to revisit this. The goal would be that I could simply do:
type InternalLinkProps extends React.HTMLProps<HTMLAnchorElement> & NextLinkProps
Forward Ref and Component Name issue
When using forwardRef
, I was getting ESlint warnings about not having a component name for the wrapped component. I dug into it a bit, but the proper solution seemed fairly verbose for this component and I chose to suppress the warnings with // eslint-disable-next-line react/display-name
Conclusion
This is my first time converting existing components utilizing Material-UI to TypeScript. If you have a better solution or find a typo, please point it out in the comments. Additionally, check out other programming articles at blainegarrett.com or follow me on twitter @blainegarrett.
Image Credit: IMG_20170829_165725 by leaf watoru is licensed under CC BY-SA 2.0