Skip to content

Instantly share code, notes, and snippets.

@jeffpeterson
Last active October 10, 2022 03:49
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save jeffpeterson/45196e85b252639a3c01b231b0cdeabe to your computer and use it in GitHub Desktop.
Save jeffpeterson/45196e85b252639a3c01b231b0cdeabe to your computer and use it in GitHub Desktop.
UI Components

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. <Button primary /> instead of <Button blue />
      • 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 looks very good
// src/components/ui/Button.tsx

import classnames from "classnames"
import * as css from "./Button.less"

export default ({primary, secondary, tertiary, flat, ...rest}: Props) =>
  <button className={classnames([css.Button, {
    [css.primary]: primary,
    [css.secondary]: secondary,
    [css.tertiary]: tertiary,
    [css.flat]: flat,
  })]} {...rest} />

Usage:

<Button primary flat>
  Click me
</Button>

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:

// 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:

// src/components/MyContacts.tsx

import * as Card from "./ui/Card"

export const MyContacts = ({contacts}) =>
  <Card.Root>
    <Card.Head big>
      <Card.Title>My Contacts</Card.Title>
    </Card.Head>

    <Card.Section slim>
      {contacts.map(contact =>
        <Card.Row>
          <Card.Cell>{contact.name}</Card.Cell>
          <Card.Cell>{contact.phone}</Card.Cell>
        </Card.Row>
      }
    </Card.Section>
  </Card.Root>

We could make the default export a bit smarter to reduce tedium:

// src/components/ui/Card.tsx

export {
  Root,
  Section,
  ...
};

export default ({title, children}) => {
  const head = title
    ? <Card.Title>{title}</Card.Title>
    : null;

  return (
    <Root>
      {head ? <Card.Head>{head}</Head> : null}
      {children}
    </Root>
  )
}

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:

// 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:

// 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

// 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 (
    <Row>
      <Button onClick={() => dispatch({ type: "DecrementClicked" })}>-</Button>
      <Text bold>{state}</Text>
      <Button onClick={() => dispatch({ type: "IncrementClicked" })}>+</Button>
    </Row>
  )
}

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:

export const stateful = <A, S>(def: CompDef<A, S>) => {
  const component = (props: P) => {
    const [state, dispatch] = useReducer(def.reducer, undefined, def.init)
    return <def.default state={state} dispatch={dispatch} {...props} />
  }

  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:

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<string, ChildB.State>
  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 (
    <>
      <ChildA.default
        state={state.childA}
        dispatch={next => dispatch({ type: "ChildAUpdated", next })}
      />

      {state.childBs
        .map((childB, id) => (
          <ChildB.default
            state={childB}
            dispatch={next => 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment