Domphy

Common Patterns

State machine

Manage complex multi-state UI (wizard, upload flow, auth) with an explicit state machine:

import { toState } from "@domphy/core"

type UploadState =
  | { status: "idle" }
  | { status: "uploading"; progress: number }
  | { status: "success"; url: string }
  | { status: "error"; message: string }

const upload = toState<UploadState>({ status: "idle" })

async function startUpload(file: File) {
  upload.set({ status: "uploading", progress: 0 })
  try {
    const url = await uploadFile(file, (progress) => {
      upload.set({ status: "uploading", progress })
    })
    upload.set({ status: "success", url })
  } catch (error) {
    upload.set({ status: "error", message: (error as Error).message })
  }
}

const UploadUI = {
  div: (l) => {
    const state = upload.get(l)
    switch (state.status) {
      case "idle": return { button: "Upload file", onClick: () => fileInput.click() }
      case "uploading": return { div: `Uploading… ${state.progress}%` }
      case "success": return { a: "View file", href: state.url }
      case "error": return { div: `Error: ${state.message}` }
    }
  },
}

Reducer pattern

For complex state transitions, centralize updates in a reducer:

import { toState } from "@domphy/core"

type CartAction =
  | { type: "ADD_ITEM"; item: CartItem }
  | { type: "REMOVE_ITEM"; id: string }
  | { type: "UPDATE_QTY"; id: string; qty: number }
  | { type: "CLEAR" }

interface CartState { items: CartItem[]; total: number }

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case "ADD_ITEM":
      return { ...state, items: [...state.items, action.item] }
    case "REMOVE_ITEM":
      return { ...state, items: state.items.filter(i => i.id !== action.id) }
    case "UPDATE_QTY":
      return {
        ...state,
        items: state.items.map(i => i.id === action.id ? { ...i, qty: action.qty } : i),
      }
    case "CLEAR":
      return { items: [], total: 0 }
  }
}

const cart = toState<CartState>({ items: [], total: 0 })

function dispatch(action: CartAction) {
  cart.set(current => cartReducer(current, action))
}

// Usage
dispatch({ type: "ADD_ITEM", item: { id: "1", name: "Widget", qty: 1, price: 10 } })
dispatch({ type: "UPDATE_QTY", id: "1", qty: 3 })

Component factory pattern

Create parameterized components (like React's Higher-Order Components) as plain functions:

import { themeColor } from "@domphy/theme"

// Factory that creates a styled badge with a given variant
function badge(variant: "success" | "error" | "warning" | "info") {
  const colorMap = {
    success: "success",
    error: "error",
    warning: "warning",
    info: "info",
  } as const

  return function Badge(text: string) {
    return {
      span: text,
      style: {
        background: themeColor(colorMap[variant], 2),
        color: themeColor(colorMap[variant], 10),
        padding: "2px 6px",
        borderRadius: "4px",
        fontSize: "0.75rem",
      },
    }
  }
}

const SuccessBadge = badge("success")
const ErrorBadge = badge("error")

// Usage
{ div: [SuccessBadge("Active"), ErrorBadge("Failed")] }

Compound component pattern

Build components that share state without prop-drilling:

import { toState } from "@domphy/core"

function createAccordion() {
  const openItems = toState<Set<string>>(new Set())

  function toggleItem(id: string) {
    openItems.set(current => {
      const next = new Set(current)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }

  function Item(id: string, header: string, content: DomphyElement) {
    return {
      div: [
        {
          button: header,
          onClick: () => toggleItem(id),
          "aria-expanded": (l) => String(openItems.get(l).has(id)),
        },
        {
          div: content,
          hidden: (l) => !openItems.get(l).has(id),
        },
      ],
    }
  }

  return { Item }
}

const { Item } = createAccordion()

const FAQ = {
  div: [
    Item("q1", "What is Domphy?", { p: "UI as plain JS objects." }),
    Item("q2", "Does it use JSX?", { p: "No JSX — plain objects only." }),
    Item("q3", "Does it have a virtual DOM?", { p: "No virtual DOM." }),
  ],
}

List with key tracking

When list items change (add/remove/reorder), use _key for correct reconciliation:

import { toState } from "@domphy/core"

interface Task { id: string; text: string; done: boolean }

const tasks = toState<Task[]>([
  { id: "t1", text: "Write tests", done: false },
  { id: "t2", text: "Deploy", done: true },
])

const TaskList = {
  ul: (l) => tasks.get(l).map(task => ({
    li: task.text,
    _key: task.id,   // stable key — tells reconciler to MOVE this item, not re-create it
    class: task.done ? "done" : "",
  })),
}

Without _key, reordering a list destroys and recreates all DOM nodes. With _key, Domphy moves the existing nodes.

Async data loading pattern

import { toState, computed } from "@domphy/core"

type AsyncState<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error }

function createAsyncState<T>() {
  const state = toState<AsyncState<T>>({ status: "idle" })

  async function load(fn: () => Promise<T>) {
    state.set({ status: "loading" })
    try {
      const data = await fn()
      state.set({ status: "success", data })
    } catch (error) {
      state.set({ status: "error", error: error as Error })
    }
  }

  const data = computed((l): T | undefined => {
    const s = state.get(l)
    return s.status === "success" ? s.data : undefined
  })

  const isLoading = computed((l) => state.get(l).status === "loading")
  const error = computed((l) => {
    const s = state.get(l)
    return s.status === "error" ? s.error : null
  })

  return { state, load, data, isLoading, error }
}

Module structure

Organize a feature module:

features/
  posts/
    state.ts        ← toState, RecordState, computed
    api.ts          ← fetch functions (no Domphy imports)
    components.ts   ← DomphyElement definitions
    index.ts        ← re-exports
// features/posts/state.ts
import { toState, computed } from "@domphy/core"
import type { Post } from "./api"

export const posts = toState<Post[]>([])
export const selectedId = toState<string | null>(null)
export const selectedPost = computed((l) =>
  posts.get(l).find(p => p.id === selectedId.get(l)) ?? null
)

Debounced state

Rate-limit expensive operations triggered by rapid state changes:

import { toState, effect } from "@domphy/core"

const query = toState("")
const results = toState<SearchResult[]>([])

effect(() => {
  const text = query.get()
  if (!text) { results.set([]); return }

  const timer = setTimeout(async () => {
    const data = await search(text)
    results.set(data)
  }, 300)

  return () => clearTimeout(timer)   // cleanup cancels previous timer
})