DevelopersComponent APIs for design systemsWritten by Karl Petersson on Mon May 18 2020

There are many different ways to build components in React. This flexibility is often a good thing, but for Visly it's both a blessing and a curse. Since we're responsible for the APIs of components designed in Visly, we need to make sure that users don't feel trapped by the constraints or overwhelmed by too much flexibility.

Visly outputs a design system with interactive components that can be bound to data through their APIs. Our goal is to help you build the presentation part of components and leave logic and state management to you. When we think about API's, we're optimizing for how we can make that experience as good as possible for the developer, while also making sure that components don't easily deviate from their design when used in code. We see this as an important contract between design and implementation.

I wanted to give a little insight into how we're thinking about component APIs, and why we've chosen certain approaches.

Styling techniques

One way to provide flexibility in the styling of a component is to expose a lot of CSS props, or even the style prop directly. A downside with this is that it very quickly breaks the previously mentioned contract and it becomes difficult to reason about how the component should look and behave across your app.

Instead, we can sacrifice a bit of that (often unnecessary) flexibility and restrict the component to a finite number of Variants. Variants apply a set of styling rules to a component.

export const PrimaryButton = styled(Button)`...`
export const SecondaryButton = styled(Button)`...`

We think it's important that when you build a component in Visly, the designer can be confident that it will look the same in your app. For this reason, we heavily limit the number of styles you can bind to props. Instead, we support variants for building different versions of a component within Visly.

We do also expose a style prop on components in Visly, since some styles such as dimensions or flex properties should be configured by their parent. Hard-coding flex-grow on the outermost container of a reusable component is less than ideal since that would break its reusability across different layouts. See our previous article on layout isolated components for details on why we went with this approach. For the same reason, we also export className, so that Visly can be used in conjunction with common CSS-in-JS libraries such as styled components.

import { Button } from 'visly'

const ButtonInSpecificLayout = styled(Button)`
    flex-grow: 1;
`

Dynamic components

A common pattern to use for components that render a dynamic amount of elements is array props. It's often a solid choice for components such as Menus and Selects because it makes it easy to manage those dynamic elements.

A downside with this pattern is that as the component evolves, the API often becomes messy, with a large amount of options needed to be configured for every menu item. What if we want some of the options to have icons, or wrap one option in a tooltip? You often end up with APIs like this:

<Menu options={[
    {
        value: 'a',
        label: 'Option a',
        action: index => someAction
        icon,
        tooltip: 'text',
        keyDownHandler: event => {}
        ...
    }
]} />

The implementation details of this component quickly become tangled and the API is unwieldy to work with. Instead, we can opt to provide a more composable API using compound components. This is a convention used heavily in many popular component and design system libraries such as Reach UI and ant.design.

// Usage
<Menu>
    <Menu.Item value='a' label="Option A" icon={icon}/>
    <Tooltip text="this menu item is special">
        <Menu.Item value='b' label="Option B"/>
    </Tooltip>
</Menu>

This approach allows for simple yet powerful APIs, but often comes at the cost of being more painful to implement. For example, Menu has no control of its children until after they have mounted, so it becomes a bit tricky to support keyboard navigation for stepping between options. In Visly, behaviours like this are embedded in primitives so that users don't need to think about it. They come with built-in accessibility, focus management and keyboard navigation.

We've adopted the compound pattern for many of the more dynamic primitives in Visly. It is a good fit from an API point of view and especially important for layouts.

Layouts and composition

We often see components in a design system being mostly the smaller building blocks - buttons, switches and so forth. In Visly we think it's vital to also include layouts. Not only is composing layouts visually a powerful and fast way of building interfaces, but having those layouts as part of your design system helps with making sure that the design of the entire app is consistent. How the smallest components look and behave when put together is equally important and equally easy to get wrong, much to the frustration of both the designer and the developer.

In Visly, we need to think about APIs with respect to layouts in a certain way. Keeping state and presentation separate becomes tricky when composing layouts, because we often want to keep state as close as possible to where it's being used. To demonstrate, here's a component not built in Visly:

function EmailForm({ submit }) {
    const [email, setEmail] = useState('')
	
    return (
        <Container>
            <Input 
                value={email}
                onChange={setEmail}
                variant={isEmail(email) ? 'default' : 'error'} />
            <Spacer />
            <Button 
                disabled={!isEmail(email)}
                onClick={() => submit(email)} />
        </Container>
    )
}

This is arguably a good abstraction since it handles state for the input locally. If we wanted EmailForm to be entirely stateless, we could let its parent control the input through props. In some cases that's fine, but in other cases we end up with a lot of props and a parent component bloated with state.

In Visly, we need to make sure we can provide good abstractions while letting users manage state. This leaves us with a couple of options.

Propagating props

By allowing users to 'expose' props of components deep in layouts, we can make sure that state can be handled from outside of the view. This is essentially prop-drilling behind the scenes.

import { EmailForm } from 'visly'

function MyEmailForm(props) {
    const [email, setEmail] = useState('')
	
    return (
        <EmailForm
            value={email}
            onSubmit={() => props.submit(email)}
            inputOnChange={setEmail}
            inputVariant={isEmail(email) ? 'default' : 'error'}
            buttonDisabled={!isEmail(email)} />
    )
}

The big advantage here is that it's straightforward to use. You can import and treat the component as one singular view and bind data to it. However, as we experimented with this approach, we realised it has a few hefty disadvantages:

  1. We end up with components that have a lot of props, and naming them / exposing them through the UI is a tedious task; and
  2. There's no way to extract a sub-component into its own wrapper, meaning it's not possible to interweave logic within the view.

Using compound components and slots

I mentioned that we've adopted compounds across Visly APIs. When used in conjuction with slots, they can solve both issues outlined above.

Purely static layouts will export as one component just like in the above scenario. But whenever you need to pass down a prop or bind state to a sub-component, you have the option to explicitly pass that component through a slot.

import { EmailForm } from 'visly'

function MyEmailForm(props) {
    const [email, setEmail] = useState('')
	
    return (
        <EmailForm
            Input={
                <EmailForm.Input
                    value={email} 
                    onChange={setEmail} 
                    variant={isEmail(email) ? 'default' : 'error'} />
            }
            Button={
                <EmailForm.Button 
                    onClick={props.onSubmit} 
                    disabled={!isEmail(email)} />
            } />
  )
}

The advantages are many:

  • Simplicity - no need to expose / rename props
  • Flexibility - state can be managed at any level in the composed layout, and we can easily wrap a sub component with a custom code-built one. Slots are such a powerful concept for us because they serve as an escape hatch for when you need to embed code-defined components within Visly components.
  • Consistency - whether it's a layout or a complex primitive, we have a unified API for using them in code

However, nothing comes without trade-offs. As a layout becomes larger, we need to nest slots to pass props to a button deep in the layout. Also, this makes the design less strict, since the user can (but probably shouldn't) pass in an Input in the Button slot, which will make the component look different that what was intended when designing it in Visly. We do think there's a lot we can do to improve upon this in the near future though - this is just a start, and something we're constantly iterating on.

Closing thoughts

As we figure out and finalize the APIs of generated components in Visly, we feel it's important that you're able to design the component you want. For us, this means constantly thinking about trade-offs between simplicity and flexibility. Should we allow this aspect to be configured? Add this prop? Make this thing style:able? Our approach here is to favour simplicity, but inject flexibility where it adds the most value, and to provide escape hatches for features that aren't fully fleshed out. This is important for many reasons, but the main one is that we want Visly to be incrementally adoptable; you can start using Visly immediately with any existing systems.

If you have any questions about Visly or comments on the article, please reach out over email or Twitter. If you want to give Visly a try, please request access here.