Let's say you're writing your latest React component, and the work is flying. You're getting all the features of the component out, you have just the right amount of arguments or options, or so you think. Do you?
Typically you might write a small component with some props as arguments. Let's say a
Navbar
element.export interface INavbarProps {
transparent?: boolean
shadow?: boolean
}
export function Navbar(props: INavbarProps) {
return (<nav>{...}</nav>)
}
This is great, you've declared you're giving the option of the component being transparent, or having a shadow (or both). Now, what about links? Navbars typically have links. Well, you might go with a simple and straight-forward solution; you may use another prop as an array or record of some kind like so.
export interface INavbarLink {
title: string
url: string
}
export interface INavbarProps {
transparent?: boolean
shadow?: boolean
links?: INavbarLink[]
}
export default function Navbar(props: INavbarProps) {
return (<nav>{...}</nav>)
}
We add an interface of
INavbarLink
and give it the following properties, title
and url
, then give a prop for an array. Then we can handle the rest similar to the following implementation. export default function Navbar(props: INavbarProps) {
return (
<nav>
{props.links.map((e, i) => (
<a key={i} href={e.url}>{e.title}</a>
)}
</nav>
)
}
This simply adds an anchor element for every link. Great! You've taken an argument and rendered your list of links.
Let's see what the implementation looks like:
export function MainPage() {
return (
<Navbar
links={[
{ title: 'Home', url: '/' },
{ title: 'Search', url: '/search' }
]}
/>
)
}
But what if the user actually wants to render a
Link
element, or even a button
, or maybe something else? Well, you could add special cases to your interface like so.export interface INavbarLink {
title: string
url: string
onClick?: () => void
}
export default function Navbar(props: INavbarProps) {
return (
<nav>
{props.links?.map((e, i) =>
e.onClick ? (
<button type='button' onClick={e.onClick}>
{e.title}
</button>
) : (
<a href={e.url}>{e.title}</a>
)
)}
</nav>
)
}
Ok great, you're rendering a button if there is an
onClick
and an anchor tag if there isn't. So you've given some flexibility to let the user render the type of component they want. But what if the user wants to attach a className/styles
to it? Or maybe specify a different type
(such as submit for the button)? You could keep adding more and more props to the interface.But let me introduce you to
React.cloneElement
, the better way. With cloneElement
you can let the user specify a whole component as an argument. Instead of an array of properties, an array of JSX components!Let's rearrange the interface and first see what that would look like, passing in JSX components instead of an array of objects.
export function MainPage() {
return (
<Navbar
links={[
<a href='/'>Home</a>,
<button type='button'>Search</button>
]}
/>
)
}
Wouldn't this be great? Letting users pass in whatever components they want. You're really designating where the components are rendered but you're taking them as is. The users could even add styles to their button, or a special class to their anchor element without more props!
Let's see what this looks like to implement in Typescript. If you're using Javascript ES6, you can simply remove the types.
import React, { ReactElement} from 'react'
export interface INavbarProps {
transparent?: boolean
shadow?: boolean
links?: ReactElement[]
}
export default function Navbar(props: INavbarProps) {
return (
<nav>
{props.links?.map((e, i) =>
e
)}
</nav>
)
}
Yup! It really is that simple! You've successfully let the user give whatever component they want to render into the Navbar. Keep in mind that there are definitely some things you need to add to the element to support any element inside of the Navbar, such as a margin to separate items. Let's do that with
React.cloneElement
. This will let you add properties to the components passed into the props.import React, { ReactElement, cloneElement } from 'react'
export default function Navbar(props: INavbarProps) {
return (
<nav>
{props.links?.map((e, i) =>
cloneElement(e, {
style: {
marginLeft: '10px'
}
})
)}
</nav>
)
}
That's all there is to it! Pass the element into
cloneElement
and add whatever additional props you want. You can also detect existing props and merge your styles, classNames, etc. into the props very easily. A more complete and final implementation might look like this:import React, { ReactElement, cloneElement } from 'react'
export interface INavbarProps {
transparent?: boolean
shadow?: boolean
links?: ReactElement<HTMLElement>[]
}
export default function Navbar(props: INavbarProps) {
return (
<nav>
{props.links?.map((e, i) =>
cloneElement(e, {
className: `ml-2 ${e.props.className || ''}`
})
)}
</nav>
)
}
export function MainPage() {
return (
<Navbar
links={[
<a href='/' className='link-primary'>
Home
</a>,
<button type='button' className='btn-primary'>
Search
</button>
]}
/>
)
}
Next time, instead of reaching for more and more props, consider whether the user would perhaps want to pass a whole component instead. You get the final say and can make changes to that component. That way you can support all manner of different kinds of components, but still get the flexibility to alter them, and make it easier and better to use your component API.
Gaurav Khanna is a software engineer, previously working at Google and other top Silicon Valley Startups with over a decade of experience developing scalable apps. Follow him on Twitter @gvkhna.