Polymorphic button component for NextJS

Today I'd like to show you how to create a (nearly) multi-purpose and reusable Button component for React and NextJS (or React Router).

Why "nearly"?

Because I didn't consider all use cases. One example would be intergrating the Button component with a component library such as MUI or Bootstrap. My example uses CSS class names and external styles but is also suitable for styled components or any CSS-in-JS approach.

If you plan on using the Button component in a CSS-in-JS environment (styled-components, emotion, jss or any other) feel free to strip the className attribute and the helper function which takes care of the className prop.

Why polymorphic?

Accordiong to Dictionary.com, the term "Polymorphic" refers to

having more than one form or type

What does this mean in the context of front-end web development, React and NextJS? A polymorphic button component is such component which will be used regardless of its form under the hood - whether we want to render a <button>, a Router <Link> or a simple <a> (anchor) tag.

On the one hand, designers usually prefer to have consistent look for interactive elements such as buttons or links. On the other hand, we - the developers, would love to have an easy to use interface which utilizes common styles and maintains semantic and accessible HTML markup.

Use cases

We will create a component called <Button /> that will allow us to use it as a <button /> of any kind (pure button, submit or reset), a router <Link /> (React Router or NextJS Router) or a simple <a /> anchor tag. On top of that, we will utilize TypeScript to provide better development experience by adding suggestions as we type, autocomplete functionality and instant error/warning feedback.

For example, the end result will look like this:

// Router Link
<Button type="link" href="/pages/example">
    I will take you to the "example" page
</Button>

// Pure button tag
<Button type="button" onClick={doSomething}>
    I will do something on click
</Button>

// Submit button
<Button type="submit">
    I will submit a form
</Button>

// External anchor tag
<Button type="anchor" rel="noopener noreferrer" href="https://atanas.info" target="_blank">
    I will open an external page
</Button>

Even though it is rarely happening, there are also times when we would like to render a good old button component without all the styles of the default <Button /> component. This will be possible with our component:

// Pure button tag without the styles
<Button type="button" onClick={doSomething} unstyled>
    I will do something on click
</Button>

// Pure button tag with custom class name and styles
<Button type="button" onClick={doSomething} unstyled className="fancy-button">
    I will do something on click
</Button>

Implementation

Tl;DR

Here is the code for the Button component:

/* eslint-disable @typescript-eslint/no-unused-vars */
import Link, { LinkProps as ILinkProps } from 'next/link';
import { FC, useMemo, AnchorHTMLAttributes, ButtonHTMLAttributes } from 'react';

import { composeClassName } from '@scripts/shared';
import type { ReactChildren } from '@scripts/types';

type CustomProps = {
	unstyled?: boolean;
};

type LinkProps = CustomProps & ILinkProps & { type: 'link'; children: ReactChildren };
type AnchorProps = CustomProps & AnchorHTMLAttributes<HTMLAnchorElement> & { type: 'anchor' };
type ButtonProps = CustomProps & ButtonHTMLAttributes<HTMLButtonElement> & { type: 'button' | 'reset' | 'submit' };

type Props = LinkProps | AnchorProps | ButtonProps;

const getClassName = <T extends Props>({ unstyled, className }: T): string | undefined => {
	if (unstyled) {
		return className;
	}

	return composeClassName('c-btn', [], [className]);
};

const AnchorButton: FC<Readonly<AnchorProps>> = (props: AnchorProps) => {
	const { type, unstyled, children, ...rest } = props;

	return <a {...rest}>{children}</a>;
};

const LinkButton: FC<Readonly<LinkProps>> = (props: LinkProps) => {
	const { type, unstyled, children, ...rest } = props;

	return <Link {...rest}>{children}</Link>;
};

const DefaultButton: FC<Readonly<ButtonProps>> = (props: ButtonProps) => {
	const { unstyled, children, ...rest } = props;

	return <button {...rest}>{children}</button>;
};

export const Button: FC<Readonly<Props>> = (props: Props) => {
	const className = useMemo(() => getClassName(props), [props]);

	if (props.type === 'anchor') {
		return <AnchorButton {...props} className={className} />;
	}

	if (props.type === 'link') {
		return <LinkButton {...props} className={className} />;
	}

	return <DefaultButton {...props} className={className} />;
};

export default Button;

Implementation notes

You probably noticed the very first line which disables the ESLint rule for unused variables. Unfortunately, that's the only drawback of this implementation but it's not so big deal when you think about it. Just tell ESLint not to complain about it.


The next important thing is the composeClassName function which is a utility function I built to help me manage CSS class names for my components. This function is implementing the BEM methodology and looks like this:

export const composeClassName = (main: string, modifiers: string[], optional: Array<string | undefined> = []): string =>
	[main, ...modifiers.filter(Boolean).map((modifier: string) => `${main}--${modifier}`), ...optional]
		.filter(Boolean)
		.join(' ');

The first argument is the block or element class name, then a list of modifiers and then a list of optional class names.

The function creates modifier class names out of the modifiers array and then merges everything into one string.

If you're not using CSS or any pre- ot post-processor for styling, feel free to disregard this function.

Just keep in mind that you probably won't need the className attributes in your Buttons as well as in any of the defined typings.


The ReactChildren is a utility type I am using. It boils down to the following:

export type ReactChild = string | ReactNode;
export type ReactChildren = ReactChild | ReactChild[];

Then you will find my custom props for the Button component:

type CustomProps = {
	unstyled?: boolean;
	className?: string;
};

Then I defined three different types for each of the supported Button types:

type LinkProps = CustomProps & ILinkProps & { type: 'link'; children: ReactChildren };
type AnchorProps = CustomProps & AnchorHTMLAttributes<HTMLAnchorElement> & { type: 'anchor' };
type ButtonProps = CustomProps & ButtonHTMLAttributes<HTMLButtonElement> & { type: 'button' | 'reset' | 'submit' };

The LinkProps combines the custom props for my component as well as the LinkProps from next/link (which I renamed to ILinkProps 😈) and the props specific to the link type <Button />.

The AnchorProps combines the custom props for my component as well as the default props which an HTML anchor tag can receive and the props specific to the anchor type <Button />.

The ButtonProps combines the custom props for my component as well as the default props which an HTML button tag can receive and the props specific to the button type <Button />.


After this you will find a union type of the three types above. The union type is used in the <Button /> component as well as in the three helper components.

type Props = LinkProps | AnchorProps | ButtonProps;

The rest of the implementation is just declaring and using several component types.

React Router vs NextJS

The implementation above is used in a NextJS application but it can be easily converted to a regular React application which uses React Router.

All you need to do is replace the following line

import Link, { LinkProps as ILinkProps } from 'next/link';

with the following line

import { Link, LinkProps as ILinkProps } from 'react-router-dom';

Final thoughts

It is important to mention why we are doing this custom <Button /> implementation - the reason is type safety and better developer experience.

As long as you pass the type prop as a first prop to your Button component, you will receive autocomplete suggestions based on the type you used.

If you added a type="anchor", Typescript will suggest all props which are accepted by the <a /> tag.

If you added a type="button", Typescript will suggest all props which are accepted by the <button /> tag.


Go back

Send me your message

Trusted by

  • Duke University brand image
  • Emailio brand image
  • E.ON brand image
  • Kinetik Automotive brand image
  • Robert Ladkani brand image
  • SOD 64 brand image
  • Three11 brand image
  • dmarcian brand image
  • htmlBurger brand image
  • htmlBoutique brand image
  • 2create brand image