How I adapt reusable UI components to different contexts

I've built several reusable user-interface (UI) components, and they always serve the single purpose of “acting as a baseline in which more complex use-cases can be formed.”

To make sure newly generated use-cases can adapt to different functional (how it works) and design (how it looks) contexts, base components must first do two things:

  1. keep the base or common properties abstracted, and,
  2. make sure the instance or unique properties are extended-able and/or override-able.

Functional Context

With functional contexts, extending and/or overriding the base component is obvious and relatively straightforward. For example, let's assume we have a base button component called Button that accepts a series of props like type and onClick:

TypeScript
interface IButtonProps {
type: "button" | "submit";
onClick?: () => void;
children: React.ReactNode;
classNames?: string;
}
// The props `type`, `onClick`, and `children`
// will be different for every instance of <Button/>
function Button({type = "button", onClick, children}: IButtonProps) {
return (
<button {...{type, onClick}}>
{children}
</button>
)
}

We can use the base Button component like so:

TypeScript
<Button {...{type: "submit", onClick: handleClick}}>
Reset Password
</Button>

Tip

The base button component can be re-written with the props spread out, so that there isn't a need for explicit declarations.

TypeScript
function Button({children, ...props}: IButtonProps) {
return (
<button {...props}>
{children}
</button>
)
}

Design Context

There are four ways I go about adapting (extending and/or overriding) UI elements to different design contexts.

  1. With the HTML class attribute.
  2. With HTML custom data attributes.
  3. With Styled Components.
  4. With Tailwind CSS.

With the HTML class attribute

This is the simplest way to go. If we rewrite the Button component from the example above:

TypeScript
function Button({ classNames, children, ...props }: IButtonProps) {
return (
<button {...{class: classNames, ...props}}>
{children}
</button>
);
}

we can pass in class names like so:

TypeScript
<Button type="button" classNames="is-loading full-width">
Reset Password
</Button>

The pattern above works if you want to override the class names in the base Button component. However, if what you're looking to do is extend the class names, instead, you would need a package like classnames, so you can define the base class like so:

TypeScript
import cn from 'classnames';
function Button({classNames, children, ...props}) {
return (
<button {...{ class: cn(`btn`, classNames), ...props}}>
{children}
</button>
)
}

Then use it:

TypeScript
<Button type="button" classNames="is-loading full-width">
Reset Password
</Button>

React will render:

TypeScript
<button type="button" class="btn is-loading full-width">
Reset Password
</button>

With regards...

to styling the Button component, the examples in With the HTML class attribute assumes that you are indeed styling your button with the class attribute. Something like:

CSS
.btn {}
.is-loading {}
.full-width {
width: 100%;
}

With HTML custom data attributes

This is similar to With the HTML class attribute except that using custom data attributes can be more semantic and readable.

Consider the Button component below, alongside the data-* attributes.

TypeScript
interface IButtonProps {
type: 'button' | 'submit';
children: React.ReactNode;
}
function Button({ children, ...props }: IButtonProps) {
return (
<button data-is-loading="false" data-full-width="false" {...props}>
{children}
</button>
);
}

You can use the Button component like so:

TypeScript
<Button type="button" data-is-loading="true" data-full-width="true">
Reset Password
</Button>

React will render:

TypeScript
<button
data-is-loading="true"
data-full-width="true"
type="button"
>
Reset Password
</button>

Also, the...

examples for With HTML custom data attributes, the assumption is that you're styling your button component with styles like:

CSS
[data-is-loading="true"] {}
[data-full-width="true"] {}

The `props`...

data-is-loading and data-full-width will be overriden because of the {...props} declaration at the end of the list of props for button in the Button component. Ideally, this is often what you want, an override. Data attributes are more "distinct" in nature and typically will be used for a single purpose. I haven't found a use-case where going through the trouble to extend them is worth it. Until then 🤞.

With Styled Components

We can also use Styled Components.

Firstly, we'll have a base button as usual. But this time, we'll replace button with the styled component StyledButton.

TypeScript
import styled from 'styled-components';
const StyledButton = styled.button`
/* Some base styles */
`;
function Button({children, ...props}) {
return (
<StyledButton {...props}>
{children}
</StyledButton>
)
}

Secondly, we'll make StyledButton dynamic by accepting, as a prop, a customizable styled button component. let's call it CustomStyledButton.

TypeScript
import styled from 'styled-components';
export const StyledButton = styled.button`
/* Some base styles */
`;
function Button({
CustomStyledButton = StyledButton,
children,
...props
}){
return (
// If the`CustomStyledButton` prop is passed in,
// it will override `StyledButton`, if not, it
// defaults to `StyledButton`
<CustomStyledButton {...props}>
{children}
</CustomStyledButton>
)
}

Thirdly, we'll adapt the base button style by overriding or extending it.

// overriding:

TypeScript
import styled from 'styled-components';
const StyledLoginButton = styled.button`
/* Specific styles for the login button
and general styles for all buttons
*/
`;
export function LoginForm() {
return (
<form>
{/* Other elements */}
{/* `StyledLoginButton` AKA `CustomStyledButton`
will override `StyledButton` in the `Button`
component
*/}
<Button CustomStyledButton={StyledLoginButton}>
Log in
</Button>
</form>
)
}

// extending:

TypeScript
import styled from 'styled-components';
import {StyledButton} from '~/ui/Button';
// `StyledRegisterButton` will extend `StyledButton`
const StyledRegisterButton = styled(Button)`
/* Specific styles for the register button */
`;
export function RegisterForm() {
return (
<form>
{/* Other elements */}
<Button CustomStyledButton={StyledRegisterButton}>
Log in
</Button>
</form>
)
}

With Tailwind CSS

This is similar to With the HTML class attribute in that you can define a list of class names for the base component, then use a package like classnames to merge them together.


🙏

Please send me a DM on Twitter to correct any error(s) that you may have found or if you have any suggestions to help improve this article.