Rather than have a few components that do many things, we should prefer to create many smaller components that we can put together (compose) to achieve the desired effect.
TL;DR: Whenever your component starts accepting too many props, it's usually a sign that the component could be broken up into separate parts, where each part is only concerned with one thing. By separating out the components they become easier to read, re-usable in other areas, and easily extended/overridden when required.
A large component
PageHeader.tsx
import React from 'react'
import styled from 'styled-components'
import {useMediaQuery} from '@material-ui/core'
import {breakpoints} from 'lib/ui/theme'
import Button from 'lib/ui/Button'
import {Title, Description} from 'lib/ui/typography'
export type PageHeaderProps = {
disabled?: boolean
title: string
smTitle?: string
buttonText: string
smButtonText?: string
description?: string
'aria-label'?: string
onClick?: () => void
}
export default function PageHeader(props: PageHeaderProps) {
const type = props.onClick ? 'button' : 'submit'
const matches = useMediaQuery(`(max-width: ${breakpoints.sm})`)
const title = matches && props.smTitle ? props.smTitle : props.title
const buttonText =
matches && props.smButtonText ? props.smButtonText : props.buttonText
const DescriptionBox = () => {
if (props.description) {
return (
<StyledBox>
<Description>{props.description}</Description>
</StyledBox>
)
}
return null
}
return (
<Container>
<MDFlexBox>
<Title>{title}</Title>
<Button
type={type}
variant="contained"
color="success"
aria-label={props['aria-label'] ? props['aria-label'] : 'submit'}
disabled={props.disabled}
onClick={props.onClick}
>
{buttonText}
</Button>
</MDFlexBox>
<SMFlexBox>
<Title>{props.smTitle ? props.smTitle : props.title}</Title>
<Button
type={type}
variant="contained"
color="success"
aria-label={props['aria-label'] ? props['aria-label'] : 'submit'}
disabled={props.disabled}
onClick={props.onClick}
>
{props.smButtonText ? props.smButtonText : props.buttonText}
</Button>
</SMFlexBox>
<DescriptionBox />
</Container>
)
}
Contains lots of behavior:
- Header layout info
- Title values for various widths
- Button info
- Conditionally rendering via a nested component
- This approach has also had to duplicate the components to handle the different layouts. Duplication is generally bad, let's avoid it where we can.
We could say this component is very specific. It only renders a single layout, and pre-defined children. Any variations would either require:
- Copy-pasting behavior
- Adding new props, then using more ifs, or other logical operators to determine what to render/style.
Using PageHeader.tsx
<PageHeader
onClick={save}
disabled={processing}
title="Add form"
buttonText="Save Changes"
smButtonText="Save"
aria-label="save form"
/>
- Not sure what is being clicked / disabled / label: button? title?
- Results in many props with long names - buttonText, smButtonText
- Need to go into , and scan a lot of code to find out when smButtonText is rendered.
Using smaller components
Let's start with how we'd like to use it.
<PageHeader>
<Title text="Add form"/>
<Button aria-label="save form"
onClick={save}
disabled={processing}
text="Save Changes"
textCollapsed="Save"
/>
</PageHeader>
-
<PageHeader>
is only concerned with the layout - Clearer where each prop is applied.
- If we're only interested in behavior, we'll only need to look at that component.
- Smaller, clearer prop names.
- We know the title will eventually need a textCollapsed too, so we'll just use a text prop to keep it consistent with the button
PageHeader/index.tsx
export default function PageHeader(props: {children: JSX.Element[]}) {
return <Container>{props.children}</Container>
}
const Container = styled.div`
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: ${(props) => props.theme.spacing[21]} !important;
@media (min-width: ${(props) => props.theme.breakpoints.sm}) {
margin-bottom: ${(props) => props.theme.spacing[19]} !important;
}
`
- Only concerned with layout
PageHeader/Title.tsx
export default function Title(props: {text: string; textCollapsed?: string}) {
const {text, textCollapsed} = props
return (
<>
<DesktopTitle>{text}</DesktopTitle>
<Include if={Boolean(textCollapsed)}>
<MobileTitle>{textCollapsed}</MobileTitle>
</Include>
</>
)
}
const DesktopTitle = DesktopOnly(TitleText)
const MobileTitle = MobileOnly(TitleText)
- Only concerned with title related behavior
- Not mixing style with rendering. Not using media query / breakpoints inside component body.
DesktopOnly/MobileOnly
Style utility component that wraps whatever component you pass in, so that it only shows at the given width.
export const DesktopOnly = (component: React.FC<any>) => styled(component)`
display: none;
@media screen and (min-width: ${(props) => props.theme.breakpoints.sm}) {
display: block;
}
`
- Only concerned with showing/hiding at various breakpoints
PageHeader/Button.tsx
Similar to title, but we'll also extend the base <Button>
, and set some default props.
export default function Button(
props: Partial<ButtonProps> & {
text: string
textCollapsed?: string
},
) {
const {text, textCollapsed, ...buttonOverrides} = props
const buttonProps: Partial<ButtonProps> = {
variant: 'contained',
color: 'success',
...buttonOverrides,
}
return (
<>
<DesktopButton {...buttonProps}>{text}</DesktopButton>
<Include if={Boolean(textCollapsed)}>
<MobileButton {...buttonProps}>{textCollapsed}</MobileButton>
</Include>
</>
)
}
- Props can still be overridden.
- Clear what is rendered where, and when.
Top comments (1)
Good read!
What is the TitleText you are referring to in the Title component?
I don't understand what that is.