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, EventEmitter, type BindReturn, type BaseComponentEvents, type BaseProps } from "@duct-ui/core/blueprint"
// 1. Define your event interface
export interface ButtonEvents extends BaseComponentEvents {
click: (el: HTMLElement) => void
stateChange: (el: HTMLElement, state: string) => void
}
// 2. Define your logic interface
export interface ButtonLogic {
setDisabled: (disabled: boolean) => void
getLabel: () => string
}
// 3. Define your props interface
export interface ButtonProps {
label: string
disabled?: boolean
class?: string
'on:click'?: (el: HTMLElement) => void
}
// 4. Render function - pure presentation
function render(props: BaseProps<ButtonProps>) {
const { label, disabled = false, class: className = '', ...moreProps } = props
return (
<button
class={`btn ${className}`}
disabled={disabled}
{...moreProps}
>
{label}
</button>
)
}
// 5. Bind function - behavior and logic
function bind(el: HTMLElement, eventEmitter: EventEmitter<ButtonEvents>, props: any): BindReturn<ButtonLogic> {
const button = el as HTMLButtonElement
function handleClick(e: Event) {
if (!button.disabled) {
eventEmitter.emit('click', button)
}
}
button.addEventListener('click', handleClick)
function setDisabled(disabled: boolean) {
button.disabled = disabled
}
function getLabel(): string {
return button.textContent || ''
}
function release() {
button.removeEventListener('click', handleClick)
}
return {
setDisabled,
getLabel,
release
}
}
// 6. Create and export the component directly
const id = { id: "my-app/button" }
const Button = createBlueprint<ButtonProps, ButtonEvents, ButtonLogic>(
id,
render,
{ bind }
)
export default Button
Step-by-Step Guide
Step 1: Define TypeScript Interfaces
Start by defining clear contracts for your component. This provides excellent IDE support and catches errors early.
// Events your component can emit
export interface MyComponentEvents extends BaseComponentEvents {
change: (el: HTMLElement, value: string) => void
submit: (el: HTMLElement, data: FormData) => void
}
// Methods your component exposes for external control
export interface MyComponentLogic {
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
}
Step 2: 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" {...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
{...props}
/>
)
}
Step 3: Implement the Bind Function
The bind function is where all your component logic lives. It receives the DOM element, event emitter, props, and any loaded data.
function bind(
el: HTMLElement,
eventEmitter: EventEmitter<InputEvents>,
props: any
): BindReturn<InputLogic> {
// 1. Get references to DOM elements
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 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 {
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 UserSelectData {
users: Array<{ id: string, name: string, email: string }>
}
async function load(el: HTMLElement, props: any): Promise<UserSelectData> {
// 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: any,
loadData?: UserSelectData
): 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...
return { /* ... */ }
}
// Create blueprint with load function
export default function makeUserSelect() {
return createBlueprint<UserSelectProps, UserSelectEvents, UserSelectLogic, UserSelectData>(
{ id: "my-app/user-select" },
render,
{ load, bind }
)
}
Accessing Component Logic
Components expose their logic for external control using two patterns:
// Import and use components directly
import { createRef } from '@duct-ui/core'
import Button from './Button' // Your component
const buttonRef = createRef<ButtonLogic>()
function MyApp() {
return (
<Button
ref={buttonRef}
label="Click me"
class="btn btn-primary"
on:click={handleClick}
/>
)
}
// Access component methods via ref
buttonRef.current?.setDisabled(true)
buttonRef.current?.setLabel('New Text')
Best Practices
Do
- ✓ Use TypeScript interfaces for everything
- ✓ Keep render functions pure
- ✓ Export components directly, not factory functions
- ✓ Use refs for component logic access
- ✓ Use data attributes for element selection
- ✓ Always implement the release function
- ✓ Validate props in bind, not render
- ✓ Use descriptive component IDs
- ✓ Emit events for important state changes
- ✓ Handle errors gracefully in load functions
Don't
- ❌ Put side effects in render functions
- ❌ Forget to remove event listeners
- ❌ Use global variables for component state
- ❌ Query DOM elements by tag name or class
- ❌ Mutate props directly
- ❌ Create components without TypeScript types
- ❌ Ignore async errors in load functions
Testing Components
Duct components are easy to test because of their explicit structure:
// Test example
import Button from './Button'
import { createRef } from '@duct-ui/core'
describe('Button Component', () => {
let container: HTMLElement
let buttonRef: any
beforeEach(() => {
buttonRef = createRef()
// Render component
container = document.createElement('div')
document.body.appendChild(container)
// Render the button component
const buttonElement = Button({
ref: buttonRef,
label: 'Test Button'
})
container.appendChild(buttonElement)
})
afterEach(() => {
document.body.removeChild(container)
})
test('should render with correct label', () => {
const button = container.querySelector('button')
expect(button?.textContent).toBe('Test Button')
})
test('should disable when setDisabled(true) is called', () => {
buttonRef.current?.setDisabled(true)
const button = container.querySelector('button')
expect(button?.disabled).toBe(true)
})
test('should handle click events', () => {
const button = container.querySelector('button')
const clickSpy = jest.fn()
// Set up click handler
button?.addEventListener('click', clickSpy)
button?.click()
expect(clickSpy).toHaveBeenCalled()
})
})