Stop Building Your UI Components like this❌
Harsh Choudhary
Posted on December 22, 2021
It is true everyone feels elated abstracting the most often copy-pasted code in the codebase into a reusable component. One bad effect of that is hasty abstractions which is a story for another day, today's agenda is to learn how to make actually reusable components.
Lots of the time while abstracting the reusable component it turns into a mess of props. You've probably seen "reusable" components with over 50 props! Those end up being enormously difficult to use and maintain, at the same time it brings performance problems and actual bugs that are hard to track.
Adding one prop for a new use-case is not just an if statement and you end up making a lot of changes in the component making the code size huge and unmaintainable.
But if we're mindful of the kinds of abstractions we create, then we can make something truly easy to use and maintain, is bug-free, and not so big that users pay the download penalty.
Kent C dodd's has explained the problem in-depth, give it a watch:
Simply React
How does a reusable component looks like?
We've got a LoginFormModal
component that's abstracted the modal for the login and registration forms. The component itself isn't all that complicated and only accepts a handful of props, but it's pretty inflexible and we'll need to create more modals throughout the application so we want something that's a lot more flexible.
<LoginFormModal
onSubmit={handleSubmit}
modalTitle="Modal title"
modalLabelText="Modal label (for screen readers)"
submitButton={<button>Submit form</button>}
openButton={<button>Open Modal</button>}
/>
Towards the end, we will create our component which can be used like this:
<Modal>
<ModalOpenButton>
<button>Open Modal</button>
</ModalOpenButton>
<ModalContents aria-label="Modal label (for screen readers)">
<ModalDismissButton>
<button>Close Modal</button>
</ModalDismissButton>
<h3>Modal title</h3>
<div>Some great contents of the modal</div>
</ModalContents>
</Modal>
But isn't this more code and more complex than just passing the prop😒.
We have passed the responsibility to the user of the component rather than the creator, this is called inversion of control. It's definitely more code to use than our existing LoginFormModal
, but it is simpler and more flexible and will suit our future use cases without getting any more complex.
For example, consider a situation where we don't want to only render a form but
want to render whatever we like. Our Modal
supports this, but the
LoginFormModal
would need to accept a new prop. Or what if we want the close
button to appear below the contents? We'd need a special prop called
renderCloseBelow
. But with our Modal
, it's obvious. You just move the
ModalCloseButton
component to where you want it to go.
Much more flexible, and less API surface area.
This pattern is called Compound Component - components that work together to form a complete UI. The classic example of this is <select>
and <option>
in HTML.
It is widely used in many real-world libraries like:
Let's create our first Compound Component while building a reusable modal
.
Building our first compound component
import * as React from 'react'
import VisuallyHidden from '@reach/visually-hidden'
/* Here the Dialog and CircleButton is a custom component
Dialog is nothing button some styles applied on reach-dialog
component provided by @reach-ui */
import {Dialog, CircleButton} from './lib'
const ModalContext = React.createContext()
//this helps in identifying the context while visualizing the component tree
ModalContext.displayName = 'ModalContext'
function Modal(props) {
const [isOpen, setIsOpen] = React.useState(false)
return <ModalContext.Provider value={[isOpen, setIsOpen]} {...props} />
}
function ModalDismissButton({children: child}) {
const [, setIsOpen] = React.useContext(ModalContext)
return React.cloneElement(child, {
onClick: () => setIsOpen(false),
})
}
function ModalOpenButton({children: child}) {
const [, setIsOpen] = React.useContext(ModalContext)
return React.cloneElement(child, {
onClick: () => setIsOpen(true),
})
}
function ModalContentsBase(props) {
const [isOpen, setIsOpen] = React.useContext(ModalContext)
return (
<Dialog isOpen={isOpen} onDismiss={() => setIsOpen(false)} {...props} />
)
}
function ModalContents({title, children, ...props}) {
return (
//we are making generic reusable component thus we allowed user custom styles
//or any prop they want to override
<ModalContentsBase {...props}>
<div>
<ModalDismissButton>
<CircleButton>
<VisuallyHidden>Close</VisuallyHidden>
<span aria-hidden>×</span>
</CircleButton>
</ModalDismissButton>
</div>
<h3>{title}</h3>
{children}
</ModalContentsBase>
)
}
export {Modal, ModalDismissButton, ModalOpenButton, ModalContents}
Yay! We did quite some work, we can now use the above component like:
<Modal>
<ModalOpenButton>
<Button>Login</Button>
</ModalOpenButton>
<ModalContents aria-label="Login form" title="Login">
<LoginForm
onSubmit={register}
submitButton={<Button>Login</Button>}
/>
</ModalContents>
</Modal>
The code is more readable and flexible now.
Bonus: Allowing users to pass their own onClickHandler
The ModalOpenButton
and ModalCloseButton
set the onClick
of their child button so that we can open and close the modal. But what if the users
of those components want to do something when the user clicks the button (in
addition to opening/closing the modal) (for example, triggering analytics).
we want to create a callAll method which runs all the methods passed to it like this:
callAll(() => setIsOpen(false), ()=>console.log("I ran"))
I learned this from Kent's Epic React workshop. This is so clever, I love it.
const callAll = (...fns) => (...args) => fns.forEach(fn => fn && fn(...args))
Let's use this in our components:
function ModalDismissButton({children: child}) {
const [, setIsOpen] = React.useContext(ModalContext)
return React.cloneElement(child, {
onClick: callAll(() => setIsOpen(false), child.props.onClick),
})
}
function ModalOpenButton({children: child}) {
const [, setIsOpen] = React.useContext(ModalContext)
return React.cloneElement(child, {
onClick: callAll(() => setIsOpen(true), child.props.onClick),
})
}
The power can be used by passing an onClickHandler
to our custom button like this:
<ModalOpenButton>
<button onClick={() => console.log('sending data to facebook ;)')}>Open Modal</button>
</ModalOpenButton>
Conclusion
Don't make hasty abstractions and don't leave everything to props. Maybe it is a simple component now but you don't know what use-cases you would need to cover in future, don't think of this as the trade-off between time and maintainability, the complexity can grow exponentially.
Levitate the power of composition in React with compound components and make your life easier.
Also, check Kent's Epic React Course where I learnt about Compound components patterns and a lot more.
A little about me I am Harsh and I love to code. I have been doing this since 16. I feel at home while building web apps with React. I am currently learning Remix.
If you liked the blog, Let's Connect! I am planning to bring more such blogs in the Future.
Know more about me: Harsh choudhary
Check my Testing hooks blog or how to build generic custom hook blog.
Posted on December 22, 2021
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.