# Composable UI
We’ll be splitting our components into two kinds: "UI Components" and "Domain
Components".
## UI Components
This is the design system I use for all of my projects. The goal is to reduce
the complexity of building UI by an order of magnitude or more. The principles
aren’t specific to React and apply generally to any web UI.
The guiding principle is that "flexibility is a function of constraint". We can
take the Lego brick as an example. We constrain all bricks to use a specific
pattern of studs and anti-studs. In exchange, every brick joins with any other
brick. In the same way, this system constrains our UI components to ensure that
they can effortlessly fit together. One other constraint inherent in physical
bricks is that they look and work in the same way regardless of where they are
placed; this property is very often lost in virtual systems and is a critical
property for efficient UI construction.
The constraints (defined below) on our UI components will give us several
benefits:
- Lightning-fast structural changes
- Using existing components has almost zero cost
- Prototyping new features can be done in minutes instead of days
- Simple and fast visual development
- UI components have no behavior and so can easily be built into a single-page
"style guide"
- This means new UI components can be designed in isolation with confidence
that they will simply slot into the app
- Write less CSS
- CSS can be incredibly complicated and it rewards cleverness
- Obscure properties and techniques are often vital to reduce complexity
- Reifying our UI language ensures that each developer isn’t required to be a
CSS wizard
- Most devs won’t need to write CSS at all
- Faster UI design
- Any features using existing UI components can be described with only
wireframes or simply written-out
- Pixel-perfect designs are unnecessary except when designing new components
UI components:
- Serve as a design language
- "flat secondary button" should have specific meaning
- The terminology should be aligned with designers
- Because no behavior is associated with the components, anything you see in
the app can be visually replicated without styling new elements from scratch
- Be aware when designs use existing components vs creating new ones
- Using existing UI should be lightning fast; creating new ones might be a
time investment
- Do not manage their own external layout
- UI components should have no external margins, max-widths, etc.
- They should generally be `display: block` and fill the area available to
them
- Clarification: any `display` that is block _externally_ is fine (flex,
grid, etc)
- Block-level elements can always be placed inside an `inline-block` element
to make them behave inline
- There will likely be a few `display:inline` components: Text, Link, etc.
- If it ever feels ambiguous (e.g. Button), default to the block style
- As a last resort, you can add an `inline` variant
- Have variants
- Variants allow for different versions of a component. Generally, they map to
a css classname.
- They have _semantic_ names that describe intention rather that any
particular outcome
- e.g. `` instead of ``
- e.g. `positive/negative` instead of `green/red`
- Note: I’ve seen variants that are more like enums (`kind="primary"`,
`kind="secondary"`, etc), but personally I find it a bit too verbose even if
the prop types feel a bit cleaner.
- I usually create a variant for each implemented pseudo-class
- e.g. `.Button:hover, .hover { … }`
- Are not encapsulating
- Rather than hiding behavior, UI Components are about codifying concepts
- UI Components never have state
- We wouldn't expect UI components with internal logic
- One slight exception here is that I often use the `default` export to
create a shorthand for more complicated UI components; see below.
- Should not visually change when moved around the DOM tree
- A UI component can not use CSS to style a child UI component
- This ensures that we can move components around without needing to adjust
styling.
- Animations: a small exception
- Most animation libraries handle the interpolation state internally; this
should be fine as the default
- For the purposes of UI Components we only care about the "stopping points"
- The stopping points should be accessible externally
- Generally can be tied to variants. E.g. `expanded={true/false}` and the
component will animate between expanded and collapsed
- Framer Motion calls these "variants"
- The important thing is that the "stopping points" are not tied to any
particular interaction (e.g. expanding on mouse over)
- I haven’t used it yet, but [Framer Motion](https://www.framer.com/motion/)
looks very good
```tsx
// src/components/ui/Button.tsx
import classnames from "classnames"
import * as css from "./Button.less"
export default ({primary, secondary, tertiary, flat, ...rest}: Props) =>
```
Usage:
```tsx
```
It's very common to expose multiple "parts" for any given UI component.
Generally, each _part_ is a single element. As an example, here's the structure
of a `Card` component that I've used in the past:
```tsx
// src/components/ui/Card.tsx
// I usually make the default export a bit smarter
export default Card
export { Root, Section, Head, Foot, Row, Cell, Title }
```
We can then use those _parts_ as building blocks to construct any number of card
varieties:
```tsx
// src/components/MyContacts.tsx
import * as Card from "./ui/Card"
export const MyContacts = ({contacts}) =>
My Contacts
{contacts.map(contact =>
{contact.name}{contact.phone}
}
```
We could make the default export a bit smarter to reduce tedium:
```tsx
// src/components/ui/Card.tsx
export {
Root,
Section,
...
};
export default ({title, children}) => {
const head = title
? {title}
: null;
return (
{head ? {head} : null}
{children}
)
}
```
The important thing is that the smarter default export doesn’t do anything that
can’t be done using the individual sub-parts. I often find that my UI Components
make very heavy use of the CSS _adjacent sibling_ operator. e.g:
```css
// src/components/ui/Card.css
.Section + .Section,
.Head + .Section {
border-top: 1px solid silver;
}
```
## Domain Components
- Define the app's behavior
- Are composed of UI Components (and other Domain Components)
- We shouldn't use raw DOM elements here (`div`, `span`, etc)
- Do not manage their own state, but instead define how it could be managed
- Whether the state is "local" or "global" is up to the consumer.
- This is critical: it gives us super fast prototyping using local-state while
allowing us to refactor to global state (redux) for near zero cost
- We can also make a simple testing/debugging harness that will work with
every domain component
Structure of a composable "domain" component:
```tsx
// src/components/SomeComponent.tsx
export type Action = ...;
export interface State { ... }
export interface Props {
dispatch(action: Action): void
state: State
...
}
export const init = (): State => ({ ... })
export function reducer(state: State, action: Action) { ... }
export default function SomeComponent(props: Props) { ... }
```
Example: `AdjustableNumber.tsx`
```tsx
// src/components/AdjustableNumber.tsx
import { Button, Row, Text } from './ui'
export type Action =
| { type: "IncrementClicked" }
| { type: "DecrementClicked" }
export type State = number
export interface Props {
dispatch(action: Action): void
state: State
}
export const init = () => 0
export function reducer(state: State, action: Action) {
switch (action.type) {
case "IncrementClicked":
return state + 1
case "DecrementClicked":
return state - 1
}
}
export default function AdjustableNumber({state, dispatch}: Props {
return (
{state}
)
}
```
Now, this component requires a particular environment in order to function. We
have a couple options:
- Wrap the component in a HoC that manages its state
- This isn’t the preferred method, but it’s quick to implement and the cost of
switching should be very low
- Nest it within the parent component’s declaration
Here’s a simple helper that takes our component definition and creates a
stateful version:
```tsx
export const stateful = (def: CompDef) => {
const component = (props: P) => {
const [state, dispatch] = useReducer(def.reducer, undefined, def.init)
return
}
component.displayName = def.default.name
return component
}
```
Our primary way of managing component state is by embedding it in the consuming
component. Here’s an example of a component that uses sub-reducers provided by
its children:
```tsx
import { Map } from "immutable"
import * as ChildA from "./ChildA"
import * as ChildB from "./ChildB"
export type Action =
| { type: "ChildAUpdated"; next: ChildA.Action }
| { type: "ChildBUpdated"; id: string; next: ChildB.Action }
| { type: "SomeLocalAction" }
export interface State {
childA: ChildA.State
childBs: Map
otherState: string
}
export function reducer(action: Action, state: State): State {
switch (action.type) {
case "ChildAUpdated":
return {
...state,
childA: ChildA.reducer(state.childA, action.next),
}
case "ChildBUpdated":
return {
...state,
childBs: state.childBs.update(action.id, childB =>
ChildB.reducer(childB, action.next),
),
}
}
}
export default function SomeParent({ state, dispatch }) {
return (
<>
dispatch({ type: "ChildAUpdated", next })}
/>
{state.childBs
.map((childB, id) => (
dispatch({ type: "ChildBUpdated", id, next })}
/>
))
.values()}
>
)
}
```
A few things to note:
- You might recognize this pattern from the Elm language where it’s referred to
as The Elm Architecture
- We can switch on a child’s actions within the reducer to customize behavior
- Imagine a SearchBar sub-component that dispatches "ReturnPressed", we could
decide to send a network request when this happens.
- We can create helper functions to automate simple nesting tasks
- Examples:
- substate nested directly under a key (e.g. `{search: SearchBar.State}`)
- a Map/List of substates that are referenced by id or index
- I’m sure there are more complicated forms of composition that I haven’t
covered
- Feel free to ask me about any ambiguous cases you come across in real-world
code