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:
- Render Phase: Component JSX is generated and inserted into DOM
- Load Phase (optional): Async data loading happens
- Bind Phase: Event listeners and logic are attached
- Runtime: Component is active and responsive
- 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.
}
}
}