Getting Started with Duct UI

By navilan

Duct UI is an opinionated framework that brings clarity to web development through explicit separation of concerns. Unlike React’s all-in-one approach, Duct clearly separates templates from logic, making your code more maintainable and easier to understand.

Why Duct?

Duct was born from the increasing complexity in debugging complex web applications where render logic, state management, and side effects are all intertwined. We believe that templates should be templates and logic should be logic.

With the rise of AI driven development and websites being interfaces to LLMs in more than
one way, the way we build and use websites are also changing. So we felt it is time for a
fresh look at how we build interactive websites. More on this later.

Your First Component

Let’s create a simple counter component to understand Duct’s approach:

import { createBlueprint, type BaseProps } from "@duct-ui/core/blueprint"

// Define your props
interface CounterProps {
  initialCount?: number
  step?: number
}

// Pure template - just structure and presentation
function render(props: BaseProps<CounterProps>) {
  const { initialCount = 0, step = 1 } = props

  return (
    <div class="counter">
      <button data-decrement class="btn">-{step}</button>
      <span data-count class="mx-4">{initialCount}</span>
      <button data-increment class="btn">+{step}</button>
    </div>
  )
}

// Logic separated from rendering
function bind(el: HTMLElement, eventEmitter, props: CounterProps) {
  const countEl = el.querySelector('[data-count]')!
  const incrementBtn = el.querySelector('[data-increment]')!
  const decrementBtn = el.querySelector('[data-decrement]')!

  let count = props.initialCount || 0
  const step = props.step || 1

  function updateDisplay() {
    countEl.textContent = count.toString()
  }

  function handleIncrement() {
    count += step
    updateDisplay()
  }

  function handleDecrement() {
    count -= step
    updateDisplay()
  }

  // Add event listeners
  incrementBtn.addEventListener('click', handleIncrement)
  decrementBtn.addEventListener('click', handleDecrement)

  return {
    release: () => {
      // Proper cleanup - remove event listeners
      incrementBtn.removeEventListener('click', handleIncrement)
      decrementBtn.removeEventListener('click', handleDecrement)
    }
  }
}

// Create the component
const Counter = createBlueprint(
  { id: "my-app/counter" },
  render,
  { bind }
)

export default Counter

Key Concepts

1. Render Function

The render function is pure - it takes props and returns JSX. No hooks, no state, no side effects. Just a template.

2. Bind Function

The bind function is where all your component logic lives. It receives the rendered DOM element and sets up all interactivity.

3. Lifecycle

Duct components have a clear, predictable lifecycle:

  • Render: Generate HTML from props
  • Load (optional): Fetch async data
  • Bind: Attach event listeners and logic
  • Release: Cleanup when component unmounts

4. No Magic

Everything in Duct is explicit. No hidden re-renders, no mysterious hook dependencies, no virtual DOM diffing. You’re in control.

Next Steps

Now that you understand the basics:

  1. Explore the component lifecycle in detail
  2. Learn about async data loading
  3. Discover how to compose components
  4. See how Duct compares to other frameworks

Welcome to a clearer way of building web applications. Welcome to Duct UI!