# 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. ` ``` 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