Duct UI

Building Components in Duct

A comprehensive guide to creating Duct components

Learn how to build Duct components step-by-step, from basic buttons to complex components with async data loading and sophisticated interactions.

Component Anatomy

Every Duct component consists of several key parts that work together in a predictable pattern:

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

// 1. Define your event interface
export interface ButtonEvents extends BaseComponentEvents {
  click: (el: HTMLElement, e: MouseEvent) => void
  dblclick: (el: HTMLElement, e: MouseEvent) => void
}

// 2. Define your props interface  
export interface ButtonProps {
  label: string
  disabled?: boolean
  class?: string
  'on:click'?: (el: HTMLElement, e: MouseEvent) => void
  'on:dblclick'?: (el: HTMLElement, e: MouseEvent) => void
}

// 3. Render function - pure presentation
function render(props: BaseProps<ButtonProps>) {
  const { label, disabled = false, class: className = '', ...moreProps } = props

  return (
    <button
      class={`btn ${className}`}
      disabled={disabled}
      {...renderProps(moreProps)}
    >
      {label}
    </button>
  )
}

// 4. Create component with domEvents (simple approach)
const id = { id: "my-app/button" }

const Button = createBlueprint<ButtonProps, ButtonEvents>(
  id,
  render,
  {
    domEvents: ['click', 'dblclick']  // Automatically handles DOM events
  }
)

export default Button

Alternative: Component with Custom Logic

For components that need custom behavior, use the bind function:

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

// Define toggle states
export type ToggleState = 'on' | 'off'

export interface ToggleEvents extends BaseComponentEvents {
  change: (el: HTMLElement, state: ToggleState) => void
}

export interface ToggleLogic {
  getState: () => ToggleState
  setState: (state: ToggleState) => void
  toggle: () => void
}

export interface ToggleProps {
  initialState?: ToggleState
  disabled?: boolean
  class?: string
  'on:change'?: (el: HTMLElement, state: ToggleState) => void
}

function render(props: BaseProps<ToggleProps>) {
  const { initialState = 'off', disabled = false, class: className = '', ...moreProps } = props

  return (
    <button
      class={`toggle ${initialState} ${className}`}
      disabled={disabled}
      data-state={initialState}
      {...renderProps(moreProps)}
    >
      <span class="toggle-handle"></span>
    </button>
  )
}

function bind(
  el: HTMLElement, 
  eventEmitter: EventEmitter<ToggleEvents>, 
  props: ToggleProps
): BindReturn<ToggleLogic> {
  const button = el as HTMLButtonElement
  let currentState: ToggleState = props.initialState || 'off'

  function updateUI() {
    button.className = `toggle ${currentState} ${props.class || ''}`
    button.setAttribute('data-state', currentState)
  }

  function handleClick() {
    if (!button.disabled) {
      toggle()
    }
  }

  function setState(newState: ToggleState) {
    if (currentState !== newState) {
      currentState = newState
      updateUI()
      eventEmitter.emit('change', el, currentState)
    }
  }

  function toggle() {
    setState(currentState === 'on' ? 'off' : 'on')
  }

  function getState() {
    return currentState
  }

  button.addEventListener('click', handleClick)

  return {
    getState,
    setState,
    toggle,
    release: () => {
      button.removeEventListener('click', handleClick)
    }
  }
}

const Toggle = createBlueprint<ToggleProps, ToggleEvents, ToggleLogic>(
  id,
  render,
  { bind }
)

export default Toggle

Step-by-Step Guide

Step 1: Choose Your Component Pattern

Duct offers two main patterns:

Simple Components (domEvents): For basic components that just need DOM event handling

const Button = createBlueprint<ButtonProps, ButtonEvents>(
  id,
  render,
  { domEvents: ['click', 'dblclick'] }
)

Complex Components (bind function): For components with custom logic and state management

const Toggle = createBlueprint<ToggleProps, ToggleEvents, ToggleLogic>(
  id,
  render,
  { bind }
)

Step 2: Define TypeScript Interfaces

Start by defining clear contracts for your component:

// Events your component can emit (include DOM event when relevant)
export interface MyComponentEvents extends BaseComponentEvents {
  change: (el: HTMLElement, value: string) => void
  submit: (el: HTMLElement, data: FormData) => void
  click: (el: HTMLElement, e: MouseEvent) => void  // DOM events include event object
}

// Methods your component exposes (only needed for bind function components)
export interface MyComponentLogic {
  getValue: () => string
  setValue: (value: string) => void
  reset: () => void
  focus: () => void
}

// Props your component accepts
export interface MyComponentProps {
  initialValue?: string
  placeholder?: string
  required?: boolean
  'on:change'?: (el: HTMLElement, value: string) => void
  'on:submit'?: (el: HTMLElement, data: FormData) => void
  'on:click'?: (el: HTMLElement, e: MouseEvent) => void
}

Step 3: Create the Render Function

The render function should be pure - no side effects, just return JSX based on props.
Think of it as your component’s initial HTML structure.

✅ Good Render Function

function render(props: BaseProps<InputProps>) {
  const {
    initialValue = '',
    placeholder = '',
    required = false,
    class: className = '',
    ...moreProps
  } = props

  return (
    <div class="input-container" {...renderProps(moreProps)}>
      <input
        type="text"
        class={`input ${className}`}
        placeholder={placeholder}
        value={initialValue}
        required={required}
        data-input
      />
      <span class="error-message hidden" data-error></span>
    </div>
  )
}

❌ Bad Render Function

function render(props: BaseProps<InputProps>) {
  // DON'T: Side effects in render
  console.log('Rendering input')

  // DON'T: Event handlers in render
  const handleClick = () => alert('clicked')

  // DON'T: Complex logic in render
  if (props.value && props.value.length > 10) {
    validateInput(props.value)
  }

  return (
    <input
      onClick={handleClick} // DON'T: Inline handlers
      {...renderProps(props)}
    />
  )
}

Step 3: Implement the Bind Function (For Complex Components)

The bind function is where all your component logic lives. It receives the DOM element,
event emitter, properly typed props, and any loaded data.

function bind(
  el: HTMLElement,
  eventEmitter: EventEmitter<InputEvents>,
  props: InputProps  // Now properly typed
): BindReturn<InputLogic> {
  // 1. Get references to DOM elements (el is the component container)
  const input = el.querySelector('[data-input]') as HTMLInputElement
  const errorEl = el.querySelector('[data-error]') as HTMLElement

  // 2. Set up internal state
  let isValid = true

  // 3. Define internal functions
  function validateInput(value: string): boolean {
    const valid = props.required ? value.trim().length > 0 : true

    if (!valid) {
      errorEl.textContent = 'This field is required'
      errorEl.classList.remove('hidden')
    } else {
      errorEl.classList.add('hidden')
    }

    isValid = valid
    return valid
  }

  // 4. Set up event handlers
  function handleChange(e: Event) {
    const value = (e.target as HTMLInputElement).value

    if (validateInput(value)) {
      eventEmitter.emit('change', el, value)
    }
  }

  input.addEventListener('change', handleChange)
  input.addEventListener('blur', handleChange)

  // 5. Define public methods
  function setValue(value: string) {
    input.value = value
    validateInput(value)
  }

  function getValue(): string {
    return input.value
  }

  function reset() {
    input.value = ''
    errorEl.classList.add('hidden')
    isValid = true
  }

  function focus() {
    input.focus()
  }

  // 6. Cleanup function
  function release() {
    input.removeEventListener('change', handleChange)
    input.removeEventListener('blur', handleChange)
  }

  // 7. Return public interface
  return {
    getValue,
    setValue,
    reset,
    focus,
    release
  }
}

Step 4: Add Async Loading (Optional)

For components that need to load data asynchronously, add a load function.
This runs after render but before bind.

interface UserSelectLoadData {
  users: Array<{ id: string, name: string, email: string }>
}

async function load(
  el: HTMLElement, 
  props: UserSelectProps
): Promise<UserSelectLoadData> {
  // Show loading state
  const loadingEl = el.querySelector('[data-loading]')
  if (loadingEl) {
    loadingEl.classList.remove('hidden')
  }

  try {
    // Fetch data from API
    const response = await fetch('/api/users')
    const users = await response.json()
    return { users }
  } catch (error) {
    console.error('Failed to load users:', error)
    return { users: [] }
  }
}

function bind(
  el: HTMLElement,
  eventEmitter: EventEmitter<UserSelectEvents>,
  props: UserSelectProps,
  loadData?: UserSelectLoadData
): BindReturn<UserSelectLogic> {
  // Hide loading indicator
  const loadingEl = el.querySelector('[data-loading]')
  if (loadingEl) {
    loadingEl.classList.add('hidden')
  }

  // Use loaded data to populate select
  if (loadData?.users) {
    const select = el.querySelector('select') as HTMLSelectElement

    loadData.users.forEach(user => {
      const option = document.createElement('option')
      option.value = user.id
      option.textContent = user.name
      select.appendChild(option)
    })
  }

  // Rest of bind logic...
  function handleChange(e: Event) {
    const select = e.target as HTMLSelectElement
    const selectedUser = loadData?.users.find(u => u.id === select.value)
    if (selectedUser) {
      eventEmitter.emit('userSelected', el, selectedUser)
    }
  }

  const select = el.querySelector('select') as HTMLSelectElement
  select.addEventListener('change', handleChange)

  return {
    getSelectedUser: () => {
      const selectedId = select.value
      return loadData?.users.find(u => u.id === selectedId)
    },
    release: () => {
      select.removeEventListener('change', handleChange)
    }
  }
}

// Create blueprint with load function - note the fourth generic type
const UserSelect = createBlueprint<
  UserSelectProps, 
  UserSelectEvents, 
  UserSelectLogic, 
  UserSelectLoadData
>(
  { id: "my-app/user-select" },
  render,
  { load, bind }
)

export default UserSelect

Accessing Component Logic

Components expose their logic for external control using refs:

// Import and use components directly (no factory functions needed)
import { createRef } from '@duct-ui/core'
import Toggle from '@duct-ui/components/toggle/toggle'

const toggleRef = createRef<ToggleLogic>()

function MyApp() {
  function handleToggleChange(el: HTMLElement, state: ToggleState) {
    console.log('Toggle changed to:', state)
  }

  return (
    <Toggle
      ref={toggleRef}
      initialState="off"
      class="my-toggle"
      on:change={handleToggleChange}
    />
  )
}

// Access component methods via ref
toggleRef.current?.setState('on')
toggleRef.current?.toggle()
const currentState = toggleRef.current?.getState()

Component Lifecycle

Understanding the lifecycle helps you place code in the right functions:

  1. Render Phase: Component JSX is generated and inserted into DOM
  2. Load Phase (optional): Async data loading happens
  3. Bind Phase: Event listeners and logic are attached
  4. Runtime: Component is active and responsive
  5. Release Phase: Cleanup when component is removed
// Load runs AFTER render, BEFORE bind
async function load(el: HTMLElement, props: MyProps) {
  // DOM exists, but no event listeners yet
  // Perfect for fetching data, setting up observers
}

// Bind runs AFTER load (if present)
function bind(el: HTMLElement, eventEmitter, props, loadData?) {
  // DOM exists, data is loaded
  // Set up event listeners, expose component logic
  
  return {
    // Public methods...
    release: () => {
      // Cleanup: remove listeners, clear timers, etc.
    }
  }
}
Pro Tip: Start simple and gradually add complexity. A basic component with just render and bind functions is often all you need. Add load functions and complex logic only when required.