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.
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
};
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
};
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"
/>
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.
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.
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>
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" />;
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>
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}
/>
);
};
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 appeoaches 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);
}
}
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>
);
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>
);
We can now start adding some tags.
If you have any questions or feedback please leave a comment here or connect via Twitter: A. Sharif