Domphy

Composing Elements

Elements are values

A Domphy element is a plain object — you can store it in a variable, pass it as a function argument, return it from a function, or put it in an array:

const Icon = { span: "✓", style: { color: "green" } }

const Row = {
  div: [Icon, { span: "Done" }],
  style: { display: "flex", gap: "8px" },
}

No instantiation, no lifecycle — just values.

Component functions

Create parameterized components as functions that return elements:

interface AlertProps {
  type: "info" | "success" | "warning" | "error"
  message: string
  onDismiss?: () => void
}

function Alert({ type, message, onDismiss }: AlertProps) {
  const icons = { info: "ℹ", success: "✓", warning: "⚠", error: "✕" }

  return {
    div: [
      { span: icons[type] },
      { p: message },
      onDismiss
        ? { button: "✕", onClick: onDismiss, "aria-label": "Dismiss" }
        : null,
    ].filter(Boolean),
    role: "alert",
    class: `alert alert-${type}`,
  }
}

// Usage
const ErrorAlert = Alert({ type: "error", message: "Something went wrong", onDismiss: () => {} })

Reactive components

A component function returns a new element every time it's called — for reactive components, use a listener-based render function:

function Counter(label: string) {
  const count = toState(0)

  return {
    div: [
      { span: (l) => `${label}: ${count.get(l)}` },
      { button: "+", onClick: () => count.set(n => n + 1) },
      { button: "-", onClick: () => count.set(n => n - 1) },
    ],
  }
}

const App = {
  div: [
    Counter("Apples"),
    Counter("Oranges"),   // each has its own independent count state
  ],
}

Each call to Counter() creates a new toState — the two counters are independent.

Slot pattern (children-like content)

Pass child elements via a prop:

function Card({
  title,
  children,
  footer,
}: {
  title: string
  children: DomphyElement | DomphyElement[]
  footer?: DomphyElement
}) {
  return {
    div: [
      { h3: title },
      { div: children },
      footer ? { div: footer } : null,
    ].filter(Boolean),
    class: "card",
  }
}

const ProfileCard = Card({
  title: "Alice",
  children: { p: "Software engineer" },
  footer: { button: "Follow" },
})

Spreading and merging elements

Merge partial elements to extend a base component:

const base = {
  button: "Click",
  style: { padding: "8px 16px" },
}

// Extend with additional style
const primary = {
  ...base,
  style: { ...base.style, background: "blue", color: "white" },
}

// Add a patch
const iconButton = {
  ...base,
  $: [...(base.$ ?? []), tooltip({ content: "Submit form" })],
}

Element arrays and conditionals

function UserMenu(user: User | null) {
  if (!user) {
    return [
      { a: "Log in", href: "/login" },
      { a: "Sign up", href: "/signup" },
    ]
  }

  return [
    { span: user.name },
    { a: "Profile", href: `/profile/${user.id}` },
    { button: "Log out", onClick: logout },
  ]
}

const Nav = {
  nav: (l) => UserMenu(currentUser.get(l)),
}

Render list with transforms

interface Post { id: string; title: string; published: boolean }

function PostList(posts: Post[]) {
  const published = posts.filter(p => p.published)

  return {
    ul: published.map(post => ({
      li: [
        { a: post.title, href: `/posts/${post.id}` },
        { span: "Published", class: "badge" },
      ],
      _key: post.id,
    })),
  }
}

Composition vs patching

UseWhen
Function returning elementReusable component with props
Patch ($)Behavior/style that applies on top of any native element
Spread (...base)Extend a specific element shape
Array of elementsMultiple sibling elements

Patches are stateless and composable — prefer them for behaviors. Functions are better when you need encapsulated state or complex DOM structure.

Lazy rendering

Defer rendering of expensive elements until needed:

import { toState } from "@domphy/core"

const showChart = toState(false)

const Dashboard = {
  div: [
    { button: "Show chart", onClick: () => showChart.set(true) },
    // Chart only renders when showChart is true
    {
      div: (l) => showChart.get(l) ? HeavyChart : null,
    },
  ],
}

null renders nothing — the Chart component's _onMount/_onRemove lifecycle runs only when it's actually added to the DOM.