An Introduction To Control Components
Pseudo-selectors like :hover
can make a huge impact on user experience, but they haven't composed well with React – until now!
So you’ve built yourself a <Button>
component, and it’s a darn good button at that. It’s got props to configure everything you’d ever need - the label, icon, colors, and even a loading spinner. It has beautiful styles for hover, active and focus states, and it even works well with screen readers.
Your button component can take anything the world throws at it! And then you realize that you don’t actually need a button after all. What you really needed was a link that looks like a button.
Luckily, you can fix this without even resorting to copy and paste! Because after a bit of searching, you stumble across the concept of as
props…
as
props (considered harmful)When you first see an as
prop, it can be something of an epiphany. Props can be anything, even components! JSX elements can have variable types!
Here’s what a simple <Button>
with an as
prop looks like in practice:
Make sense? If not, it’s probably worth taking a look at the first few free lessons in my React Fundamentals course — especially the bits on JSX and props. But to give you a two-sentence explanation: the above <Button>
component returns an element with a variable type
. By default, it uses the string "button"
as its type — but by passing an as
prop, you can set that to "a"
, Link
, or anything else that strikes your fancy.
If you work with React on a regular basis, you’ll probably have seen as
props in the wild. For instance, Styled Components supports them. You might also have seen them used with custom <Button>
components, allowing them to be somewhat hackily connected up to your router of choice’s <Link>
component (and thus improving response time when clicking the links):
You saw me make up the word hackily, so now you’re thinking — what’s so hackily about this? Well, assuming you don’t want well-typed components (hint: you do want well-typed components), I guess it does kinda work in this particular case.
But let me throw you a new requirement.
So you’ve got a button, and it’s got beautiful, animated hover styles. You nudge your mouse over the button, and it lights up like a christmas tree.
In fact, this button is looking so darn pneumatic that why wouldn’t we want a whole bar full of them?
Beautiful. There’s just one problem. When your boss comes along and tests everything on a tiny phone after you’ve been happily developing it on your vintage 2013 15" Macbook Pro and what a beautiful machine it is… well anyway, your boss doesn’t like the fact that he had to press the button 3 or 4 times to finally hit the target. The touch targets are too small. And by golly gosh, he shouldn’t be happy about that — it’s awful for user experience.
Those margins around the buttons? They need to be active. Despite being outside of the button body, they still need to be part of the button control. But it’s not like you can just add margin
or padding
to your <Button>
element — margins are inactive, and padding is internal.
Here’s the problem: your styles and behavior are all defined on one big, monolithic <Button>
component. And while as
props let you modify the behavior of that styled component, what you really want is two separate components: one which handles behaviors, and a separate one to handle the styles.
You know how when you render a <button>
or an <a>
, by default, the browser applies a bunch of styles to make them look like buttons and links?
My thesis is: in the React world, the browser`s default styles are just getting in the way. It’d make far more sense for components like <button>
and <a>
to be unstyled wrappers, with the button styles rendered as children.
So instead of:
<Button as={Link} href='/' style={{ margin: 8 }}>
Home
</Button>
You want:
<UnstyledLinkControl href='/'>
<ButtonBody style={{ margin: 8 }}>
Home
</ButtonBody>
</UnstyledLinkControl>
Looks simple enough, right? But there’s a trick: the hover styles for <ButtonBody>
need to be activated when the mouse hovers over <LinkControl>
. Or <AControl>
. Or <ButtonControl>
. Or any other control.
As it happens, there are three ways to achieve this: the naive way, the clunky way, and the best way. Let’s take a look at each.
One of the less-known features of the Styled Components library, is that it lets you use your styled components themselves as selectors. Combined with nested styles and the &
selector, this means that components can declare styles that’ll only apply when a parent is being hovered over — as in the above example.
For example, here’s how you’d add hover styles for <ButtonBody>
that activate when the mouse hovers over an <AControl>
or a <ButtonControl>
:
export const AControl = styled.a`
/* ... reset styles ... */
`
export const ButtonControl = styled.a`
/* ... reset styles ... */
`
export const ButtonBody = styled.span`
background-color: #223344;
${AControl}:hover &, ${ButtonControl}:hover & {
background-color: #223344CC;
}
`
Here’s the full example:
If you try hovering over the buttons on this version of the toolbar, you’ll see that the margins are now active; they cause the inner button’s hover styles to be activated! Of course, this approach doesn’t really solve the problem, because the <ButtonBody>
component still needs to explicitly specify each and every control that it can be used with; it’s tightly coupled.
For example, the above <ButtonBody>
component only works with standard <a>
tags — it doesn’t work with <Link>
components from your favorite routing library. And while you could fix this by creating a <LinkControl>
component and adding it to the css for <ButtonBody>
, it turns out there’s a better way…
One of the neat things about Styled Components it allows your styles to reference a theme
object. This is often used to set colors based on a global theme:
const Button = styled.button`
background-color: ${props => props.theme.buttonBackgroundColor};
`
As it happens, Styled Components also lets individual components merge new values into that theme
object, which will apply only for that component’s children.
export const ButtonControl = () => {
const theme = useContext(ThemeContext)
const patchedTheme = {
...theme,
// ... merged values
}
return (
<ThemeContext.Provider value={patchedTheme}>
...
</ThemeContext.Provider>
)
}
By feeding the Control Component’s styled component into context, it becomes possible for child components to create selectors that are specific to whatever control they’re being used in.
For example, here’s the :hover
example from before, with the :hover
selector being applied to whatever component has been added to the parentStyledComponent
property of the theme object:
export const ButtonBody = styled.span`
background-color: #223344;
${({ theme }) => theme.parentStyledControl}:hover & {
background-color: #223344CC;
}
`
Here’s a full example taking this approach — I’ve even connected it up to Navi's <Link>
component for good measure!
You can see how this pattern opens up a whole new world of possibilities… but you can also see how manually fiddling with context, nested selectors and default styles can get old fast. And that’s why there’s now a package for that!
The @retil/control
package exports everything you need to start using control components, including:
<AControl>
and <ButtonControl>
components for Styled Componentsactive
, disabled
, focus
and hover
selectors, which can be used within your styled
components and css
template stringscontrol()
function that turns any Styled Component into a context-providing Control ComponentHere’s how to refactor the above example using @retil/control
The package also exports a bunch of more advanced utilities:
createTemplateHelper()
function, in case the default template helpers (e.g. hover
) don’t suit your needsuseControlContext()
hook that returns the raw Control Context (internally, it’s stored within your Styled Components theme)AControlProps
and ButtonControlProps
types for TypeScript projectsI’d love to see what you build with @retil/control
. I’d also love to show the world — if you give it a whirl, make sure to send a [Pull Request] adding yourself to the apps using @retil/control` section in the README!
One more thing — I want to thank everyone who contributed to the discussion about this idea on Twitter!
You might be able to guess from the package name — but @retil/control
is the first React Utility that I’ll be publishing under the “retil” brand, and there’ll almost certainly be more. If you’ve got some code and you’re somehow convinced that there’s gotta be a better way of doing it, then you’re in good company. Send me a Tweet, and let’s nut out how to make a utility that can improve everyone’s React experience.
That’s it for today — happy coding! And if I don’t see you on the Twitters, I’ll see you at the next post!
Tokyo, Japan