DEV Community

Vesa Piittinen
Vesa Piittinen

Posted on

Semantic HTML in React with zero new ideas

Hello New Year! And welcome to yet another edition of my articles that have had zero planning and are simply written in one go! Enjoy the effort since long term planning and me don't often go hand in hand.

I'm about to take on a couple of known ideas and patterns and try to accomplish something that is seemingly unique. Or at least it is just something that I have not encountered as-is on the web.

What is wrong with how we do React

Over the years working with React I've grown frustrated on one particular thing: the written JSX code rarely expresses the actual underlying HTML semantics. What do I mean by this? Let's have a look at a typical Styled Components solution.

// SomeComponent.style.tsx
export const StyledList = styled.dl``
export const StyledListItem = styled.div``
export const StyledListTitle = styled.dt``
export const StyledListContent = styled.dd``

// SomeComponent.tsx
function SomeComponent() {
    return (
        <StyledList>
            <StyledListItem>
                <StyledListTitle>Title</StyledListTitle>
                <StyledListContent>Content</StyledListContent>
            </StyledListItem>
        </StyledList>
    )
}
Enter fullscreen mode Exit fullscreen mode

Hey, it is perfect DL semantics! However when examining SomeComponent itself you see no trace of <dl /> and the bunch! Sure, you can hover over the components and get type description which exposes that hey, it is a styled.dl element. Or if you build a component library you can add documentation to a Storybook that tells how to use the components.

But this doesn't answer the core issue. Young guys who have entered the industry in the past five or so years have a very hard time seeing the semantics. How do you learn a thing that you never see in the code? It is not really visible in the front of their eyes unless somebody is doing the shoveling actively.

With HTML this wouldn't be an issue. But JSX is full of components that have nothing to do with HTML.

We need to get that actual HTML back to the game! How do we do that?

Polymorfism vs. Composition

I'm not an expert with these terms and I'm not going to do the research on what the actual meaning of these two are. With code I admit I often care more about the solution than what people call it.

Anyway, Styled Components describes their as property as a polymorphic feature. It allows you to tell which component does the rendering. Basically it is just this:

function Polymorphic({ as: Component = 'div', ...props }) {
    return <Component {...props />
}

// render as div
<Polymorphic>Hello</Polymorphic>

// render as button
<Polymorphic as="button">Hello</Polymorphic>

// render as some framework Link component
<Polymorphic as={Link}>Hello</Polymorphic>
Enter fullscreen mode Exit fullscreen mode

The biggest issue here is that the supported properties should depend on the passed component. TypeScript does not support this. This means that if you make a component that supposedly just provides styles and some usability or a11y features on top whatever is given in, well, it adds a ton of complexity. You are forced to limit the list of supported things, making the feature less useful.

Most likely you only have styles and leave any other logic to some other layer, and make a multitude of components to deal with the issues you have. So you end up with things like <Button />, <LinkButton />, <TextLink />, <TextLinkButton /> and whatever else. Although the issue in this particular example is that designers love to make visual links that have to act like buttons and visual buttons that have to act like links. But that is a completely another issue and has more to do with process.

So what composition solutions can provide us?

<FormControl element={<fieldset />}>
    <FormTitle element={<legend />} />
</FormControl>
Enter fullscreen mode Exit fullscreen mode

The major gripe with this solution is that we are rendering double: first the element passed to element prop, and then the same thing again with the composing component.

But then there is a reason to this madness! Consider what this means when we're using another component:

<Button element={<Link to="/" />}>
    <HomeIcon />
    Home
</Button>
Enter fullscreen mode Exit fullscreen mode

The biggest advantage here is that we don't need to support Link properties in the Button component! That is a very troublesome case in many frameworks that we currently have. Users of Next, Gatsby, or React Router are likely very familiar with the issue: the need of making your own additional special component wrapping an already specialized component.

More code to support more code.

Generic abstraction

The minimal internal implementation for a Button component with the help of Styled Components would look like this:

// here would be CSS actually
const StyledButton = styled.button``

interface ButtonProps {
    element: JSX.Element
}

export function Button({ element }: ButtonProps) {
    return <StyledButton as={element.type} {...element.props} />
}
Enter fullscreen mode Exit fullscreen mode

We still make use of polymorfism in this case, but we don't have the type issues of a pure Styled Component. In this case we're really handling all the element props outside of our component entirely and we simply wrap a styled component to provide styles for the button. In this way the component itself becomes very focused and can do just what it needs to do, such as handle the styling concerns and additional functionality.

This means we can have just one single button component to handle all the button needs. So you can now pass in a button, a link, or maybe even some hot garbage like a div, and make it look like a button. But there is more! You can also fix the usability of any given component so you can apply ARIA attributes such as role="button" and make sure all the accessibility guidelines are met (the ones we can safely do under-the-hood).

The only requirement for a given element is that it needs to support and pass through DOM attributes. If it doesn't, well, then we are doing work that never becomes effective. However our main goal here is to make HTML semantics visible so in that sense this is a non-issue.

Completing the Button component

So why not go all the way in? Let's make a Button component that makes (almost) anything work and look like a button!

import styled from 'styled-components'

// CSS that assumes any element and making it look like a button
const StyledButton = styled.button``

const buttonTypes = new Set(['button', 'reset', 'submit'])

interface ButtonProps {
    children?: React.ReactNode
    element?: JSX.Element
}

function Button({ children, element }: ButtonProps) {
    const { props } = element ?? <button />
    // support `<button />` and `<input type={'button' | 'reset' | 'submit'} />` (or a custom button that uses `type` prop)
    const isButton = element.type === 'button' || buttonTypes.has(props.type)
    // it is really a link if it has `href` or `to` prop that has some content
    const isLink = props.href != null || props.to != null
    const { draggable = false, onDragStart, onKeyDown, role = 'button', tabIndex = 0, type } = props

    const nextProps: React.HTMLProps<any> = React.useMemo(() => {
        // make `<button />` default to `type="button"
        if (isButton && type == null) {
            return { type: 'button' }
        }

        if (!isButton && !isLink) {
            return {
                // default to not allowing dragging
                draggable,
                // prevent dragging the element in Firefox (match native `<button />` behavior)
                onDragStart: onDragStart ?? ((event: React.DragEvent) => event.preventDefault()),
                // Enter and Space must cause a click
                onKeyDown: (event: React.KeyboardEvent<any>) => {
                    // consumer side handler is more important than we are
                    if (onKeyDown) onKeyDown(event)
                    // check that we are still allowed to do what we want to do
                    if (event.isDefaultPrevented() || !(event.target instanceof HTMLElement)) return
                    if ([' ', 'Enter'].includes(event.key)) {
                        event.target.click()
                        // let a possible third-party DOM listener know that somebody is already handling this event
                        event.preventDefault()
                    }
                },
                role,
                tabIndex,
            }
        }

        return null
    }, [draggable, isButton, isLink, onDragStart, onKeyDown, role, tabIndex, type])

    // ref may exist here but is not signaled in types, so hack it
    const { ref } = (element as unknown) as { ref: any }

    return (
        <StyledButton as={element.type} ref={ref} {...props} {...nextProps}>
            {children ?? props.children}
        </StyledButton>
    )

}
Enter fullscreen mode Exit fullscreen mode

Sure, we didn't go for everything that a button could do. We ignored the styles and we ignored all possible modifiers. Instead we just focused on the core of what expectation of a button has to be:

  1. Keyboard accessible with focus indicator
  2. Announced as a button (but keep real links as links!)
  3. Fix default form submit behavior as <button /> is type="submit" if you don't let it know what it is. In my experience it is better to be explicit about type="submit".
  4. Explicitly disable default dragging behavior, buttons are not dragged. Links however can still be dragged.
  5. And do all this while letting user of the component still add extra features as needed.

The Developer Experience

So what was our goal again? Oh yes! Make that semantic HTML goodness visible. So what have we got now?

<Button>Button</Button>
// HTML:
<button class="..." type="button">Button</button>

<Button element={<button type="submit" />}>Submit button</Button>
// HTML:
<button class="..." type="submit">Submit button</button>

<Button element={<a href="#" />}>Link</Button>
// HTML:
<a class="..." href="#">Link</a>

<Button element={<a />}>Anchor</Button>
// HTML:
<a class="..." draggable="false" role="button" tabindex="0">Anchor</a>

<Button element={<div />}>Div</Button>
// HTML:
<div class="..." draggable="false" role="button" tabindex="0">Div</a>

<Button element={<Link to="#" />}>Link component</Button>
// HTML:
<a class="..." href="#">Link component</a>
Enter fullscreen mode Exit fullscreen mode

Looks good to me! Most of the time you can see what the semantic element is. Also you get the separation of concerns with the props: onClick is not a possibly mysterious click handler but you can be sure it is going to be a native click method. And the door is open for providing onClick from the Button component that doesn't provide event but instead something else!

Now the hard part is actually making all the components that would make use of this kind of composition and separation of concerns. This way might not work for every single possible case, like with select dropdown it is likely better keep the special unicorn implementation separate from a solution that makes use of native select element and all the handy usability features you get for free with it.

Without Styled Components

You can also achieve this without Styled Components by using React.cloneElement!

    return React.cloneElement(
        element,
        nextProps,
        children ?? props.children
    )
Enter fullscreen mode Exit fullscreen mode

However you need to deal with the styling, most likely className handling on your own.

A small advantage we have here is that if consumer wants to have a ref we don't need to implement React.forwardRef wrapper to our component. We also don't need to hack with the ref variable like in the Styled Components implementation, because element is passed to cloneElement and does know about it. So that is one hackier side of code less in the implementation.

Closing words

As far as buttons go there are still a lot of little things on the CSS side that I think every button component should do. However that is getting out of the topic and I guess this is becoming verbose enough as it is.

I hope you find this valuable! I've never liked living with Styled Components, and preferring being a web browser side of guy not really with TypeScript either, so I've been looking into ways to make my life more tolerable. Now that I am responsible for a company's component library I have finally the time to spend into thinking about the issues.

I feel rather good about where I've now arrived: I've found something that lets me keep code minimal and as boilerplate free as possible while providing less components that give more. However I'm yet to implement the changes so for now we still live with some extra components that only exist to patch (type) issues.

Discussion (2)

Collapse
lukeshiru profile image
Luke Shiru

One of the biggest mistakes in HTML design from my point of view is the input element. This element takes a type attribute and depending on it, it renders completely different things (both in behavior and visuals). The different types have some things in common like the value property/attribute, name and so on, but is not excuse enough to call them all input. So basically, we have this:

<input type="text" />
<input type="radio" />
<input type="checkbox" />
<input type="password" />
Enter fullscreen mode Exit fullscreen mode

When we could have this:

<input />
<radio />
<checkbox />
<password />
Enter fullscreen mode Exit fullscreen mode

That being said: Why would we replicate this behavior from React? Taking it to the extreme, we would end up with this:

<Component type="button" />
<Component type="a" />
<Component type="p" />
<Component type="strong" />
Enter fullscreen mode Exit fullscreen mode

When we can just ....

<button />
<a />
<p />
<strong />
Enter fullscreen mode Exit fullscreen mode

This isn't a good pattern. The folks at Styled Components have the styled.tagName format because they want to give you any native element with their styling wrapper on top, but your components ideally should avoid this approach because it will make maintenance a nightmare.

Cheers!

Collapse
merri profile image
Vesa Piittinen Author

I agree as far as the button use case goes. It would be better to just write <button type="button" /> directly in the code so it would always remain visible as-is, and not have any <Button /> component. It should be relatively easy to just use className directly to have the correct style by using something like CSS Modules.

However the current trend seems to be the TypeScript + componentization of everything, and thus the big project reality is having everything as a component with clean types. In that world it seems like it is a rather alien thought to only have something as CSS that you would use to an element, and you would get no type aid, only documentation that tells you how to do what you need to do. It is hard to challenge the mindset since you still totally have to have the more complex components as well.

There is also one thing I didn't remember while writing the article: how much there is a need for role="button". And the truth is it should be a rather rare use case. So in that sense the component I worked out here is overkill, and in practical use wouldn't often really do that much.

However when passing in elements and having the separation of props I fail to see the maintenance nightmare that you refer to. <Component {...allTheProps} element="button" /> is quite different to <Component {...fewRelevantProps} element={<button {...domAndReactUtilityProps} />} />.

Still, for buttons would prefer a different solution, I mostly ended up using buttons as sample for their relative ease rather than being the optimal case.