DEV Community

A. Sharif
A. Sharif

Posted on

Monolithic Components, Composable Components

Introduction

Building reusable UI components is a non trivial task, as we need to anticipate a number of things when planing for reuseability. On the one end of the spectrum we want to enable customization and on the other side we want to avoid developers doing the wrong thing, like breaking the component or displaying invalid states.

To get a better understanding of what we need to think about and consider upfront, we will build a non-trivial UI component, that displays tags. Our Tags component will take care of managing and displaying tags.

The following examples are all built with Tachyons and React, but these ideas apply to any UI component and any general styling approach.

Basics

Let's talk about Tags first. Tags should enable to add, display and delete tags.
It should also enable to style the component as needed as well as leave some room for configuring the behaviour and representation of these tags.

Our first naive approach might be to define a <Tags /> component, that expects an array of tags and displays these tags. Optionally there should be a capability to add new tags and the possibility to delete a tag. The very initial API implementation considers all these cases.

type TagsProps = {
  items: Array<string>,
  onAdd?: (tag: string) => void,
  onRemove?: (tag: string) => void
};
Enter fullscreen mode Exit fullscreen mode

Tags Component: Basic

So, we can already see that it renders a provided set of tags and displays an input element for adding new tags. This implementation also has some assumptions about these optional types. If no onAdd function is provided, we don't display an input element either, same for removing tags.

How can we style our tag representations?

One approach is to expose another prop for enabling to define the theme. We might offer two or three different options, like light, default and dark.

type Theme = "light" | "default" | "dark";

type TagsProps = {
  items: Array<string>,
  onAdd?: (tag: string) => void,
  onRemove?: (tag: string) => void,
  theme?: Theme
};
Enter fullscreen mode Exit fullscreen mode

Developers using this component can now switch between different modes, f.e. using the following declaration would return a dark themed tags component.

<Tags
  items={items}
  addItem={this.addItem}
  onRemove={this.removeItem}
  theme="dark"
/>
Enter fullscreen mode Exit fullscreen mode

Tags Component: Dark Mode

Up until now we were able to design our API to handle all expected basic use cases. But let's think about how a developer might want to use this Tag component for a minute. How could we display the input box below the tags for example? There is no way to do this with the Tags component at the moment.

Refactoring

Let's take a step back for a minute and think about how we might enable developers to freely define where the input box should be positioned. One quick way is to add another prop, which could define some sort of ordering in form of an array f.e. ordering={['tags', 'input']}. But this looks very improvised and leaves room for errors. We have a better way to solve this problem.

We can leverage composition by exposing the underlying building blocks to user land. Tags uses InputBox and Tag under the hood, we can export these components and make them available.

Tags Component: Ordering

Let's take a closer look at how the components are structured.

<div>
  <div className="measure">
    {this.state.items.map(item => (
      <Tag title={item} key={item} onRemove={this.onRemove} theme="light" />
    ))}
  </div>
  <div className="measure">
    <TagInput value={this.value} onSubmit={this.onSubmit} />
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

Interestingly we don't use the Tags component anymore, we're mapping over the tags explicitly, but we can use the TagInput directly, as it handles local state independently. Although this approach gives developers control on how to layout the tags, it also means added work that we wanted to avoid in the first place. How can we avoid having to map over these items and still enable to define the ordering? We need a better solution.

Let's define a TagItems component again.

type TagItemsProps = {
  items: Array<string>,
  onRemove?: (tag: string) => void,
  theme?: Theme
};

<TagItems items={items} onRemove={this.removeItem} theme="dark" />;
Enter fullscreen mode Exit fullscreen mode

We can decouple our TagItems component from the TagsInput component. It's up to the developer to use the input component, but also enables to define the ordering and layout as needed.

<div>
  <div className="measure">
    <TagItems items={items} onRemove={this.onRemove} />
  </div>
  <div className="measure">
    <TagInput value="" onSubmit={this.onSubmit} />
  </div>
</div>
Enter fullscreen mode Exit fullscreen mode

This is looking quite sensible already. We can explicitly define the layout and ordering of the components, without having to handle any internals manually.

Now if we think about further requirements, we can anticipate the need to define some specific styles for a rendered tag or the input box. We have exposed the main building blocks, but how can we adapt the theming to suit an existing design?

Our tag components need to address the possibility to override specific styling aspects when needed. One possible way is to add classes or inline-styles.

The better question that needs answering is if our main building blocks should even be concerned with any view information. One possible approach is to define a callback for defining what low level building block we want to actually use. Maybe some developer would like to add a different close icon?

Before we continue, let's think about some facts regarding our components.

Our TagInput component takes care of managing local state and enabling to access the tag value when a user presses enter.

The Tags component iterates over the provided tags and renders them, passing remove capabilities to every Tag component.

With these building blocks available we can already ensure that any developer can display decent looking tags. But there are limits we can already see, when some specific requirements arise in the future. Currently we have coupled state and view handling. Our next step is decouple the actual Input component, that takes care of any view concerns, from the TagsInput component, that manages state handling.

Now that we have a better understanding, let's see what further decoupling our components will bring us.

type InputProps = {
  value: string
};

const Input = ({ value, ...additionalProps }: InputProps) => {
  return (
    <input
      id="tag"
      className="helvetica input-reset ba b--black-20 pa2 mb2 db w-100"
      type="text"
      value={value}
      placeholder="Add Tag"
      {...additionalProps}
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

The above code is the smallest building block we might want to offer. It opens up the possibility to override specific stylings or even the className attribute if needed. We also don't define how the onChange or onSubmit is handled in this case. Our TagsInput passes an onChange and onKeypress prop, but maybe we want to submit via a button in a specific case.

Our TagsInput doesn't care about the actual styling and is only concerned with managing state and supplying functionalities for updating that state as well as submitting that state. For this example we will provide render prop, but other approaches like higher order components or other approaches work the same, so we can reuse the state handling logic when needed and provide our own input component if needed. The state handling in this case might not seem to be worth the effort, but we might be doing more complex things in a more advanced implementation. It should highlight the fact that we can expose state and view handling now. Developer land can freely compose and mix as needed now. Check the following example for a better understanding.

type StateType = { value: string };

class TagInput extends React.Component<TagInputProps, StateType> {
  constructor(props: TagInputProps) {
    super(props);
    this.state = { value: props.value };
  }

  onChange = (e: any) => {
    this.setState({ value: e.target.value });
  };

  onSubmit = (e: any) => {
    e.persist();
    if (e.key === "Enter") {
      this.props.onSubmit(this.state.value);
      this.setState({ value: "" });
    }
  };

  render() {
    const { value } = this.state;
    const {
      onSubmit,
      value: propsTag,
      theme,
      render,
      ...additionalProps
    } = this.props;
    const tagsInput = {
      value,
      onKeyDown: this.onSubmit,
      onChange: this.onChange,
      ...additionalProps
    };
    return this.props.render(tagsInput);
  }
}
Enter fullscreen mode Exit fullscreen mode

Our TagItems component doesn't do very much, it only iterates over the Items and calls Tag component, as already stated further up. We don't need to do much here, we can also expose the Tag component, as the mapping can be done manually when needed.

type TagItemsProps = {
  items: Array<string>,
  onRemove?: (e: string) => void,
  theme?: Theme
};

const TagItems = ({ items, onRemove, theme }: TagItemsProps) => (
  <React.Fragment>
    {items.map(item => (
      <Tag title={item} key={item} onRemove={onRemove} theme={theme} />
    ))}
  </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

Final Thoughts

This walkthrough and refactoring session, enabled us to provide a monolithic Tags as well as TagInput, Input, TagItems and Tag components. The standard way is to use the Tags component, but if there is a need for some special customization, we can now use the underlying building blocks to reconstruct the behaviour as needed.

With the upcoming release of hooks, we can even expose all the building blocks in a more explicit manner. We might not need the TagInput component anymore, we can expose a hook instead, and use this hook internally inside Tags.

A good indicator for exposing the underlying building blocks is when we need to start adding properties like components={['input']} or components={['input', 'tags']} to indicate which components we want displayed and in which ordering.

Another interesting aspect that we can observe, after breaking a monolithic into smaller blocks, is that our top level Tags can be used as a default implementation, a composition of the smaller building blocks.

type TagsProps = {
  items: Array<string>;
  onRemove: (e: string) => void;
  onSubmit: (e: string) => void;
  theme?: Theme;
};

const Tags = ({ items, onRemove, onSubmit, theme }: TagsProps) => (
  <React.Fragment>
    <div className="measure">
      <TagItems items={items} onRemove={onRemove} theme={theme} />
    </div>
    <div className="measure">
      <TagInput
        value=""
        onSubmit={onSubmit}
        render={props => <Input {...props} />}
      />
    </div>
  </React.Fragment>
);
Enter fullscreen mode Exit fullscreen mode

We can now start adding some tags.

Tags Component: Final

Find the original gist here

If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif

Top comments (0)