Skip to content

Components

Components are reusable, interactive UI elements built with Preact. They’re registered with the framework and can be mounted anywhere in your templates.

Create static/src/js/components.js in your module:

my_module/static/src/js/components.js
const { registry, html, useState } = window.fullfinity;
// Register a component
registry.component('MyButton', function({ label, onClick }) {
return html`
<button class="my-button" onClick=${onClick}>
${label}
</button>
`;
});

Mount the component in a Jinja2 template:

<div
data-component="MyButton"
data-props='{"label": "Click Me"}'
></div>

The framework automatically:

  1. Finds elements with data-component
  2. Parses data-props as JSON
  3. Renders the component into the element
registry.component('Counter', function({ initial = 0 }) {
const { useState, html } = window.fullfinity;
const [count, setCount] = useState(initial);
return html`
<div class="counter">
<button onClick=${() => setCount(c => c - 1)}>-</button>
<span>${count}</span>
<button onClick=${() => setCount(c => c + 1)}>+</button>
</div>
`;
});
registry.component('ContactForm', function({ submitUrl }) {
const { useState, html } = window.fullfinity;
const [formData, setFormData] = useState({ name: '', email: '', message: '' });
const [errors, setErrors] = useState({});
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState(false);
function updateField(field, value) {
setFormData(prev => ({ ...prev, [field]: value }));
// Clear error when field changes
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
}
function validate() {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Name is required';
if (!formData.email.includes('@')) newErrors.email = 'Valid email required';
if (!formData.message.trim()) newErrors.message = 'Message is required';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
}
async function handleSubmit(e) {
e.preventDefault();
if (!validate()) return;
setSubmitting(true);
try {
const response = await fetch(submitUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData)
});
if (response.ok) {
setSuccess(true);
setFormData({ name: '', email: '', message: '' });
}
} finally {
setSubmitting(false);
}
}
if (success) {
return html`<div class="success">Thank you! We'll be in touch.</div>`;
}
return html`
<form onSubmit=${handleSubmit}>
<div class="form-group">
<label>Name</label>
<input
type="text"
value=${formData.name}
onInput=${e => updateField('name', e.target.value)}
class=${errors.name ? 'error' : ''}
/>
${errors.name && html`<span class="error-text">${errors.name}</span>`}
</div>
<div class="form-group">
<label>Email</label>
<input
type="email"
value=${formData.email}
onInput=${e => updateField('email', e.target.value)}
class=${errors.email ? 'error' : ''}
/>
${errors.email && html`<span class="error-text">${errors.email}</span>`}
</div>
<div class="form-group">
<label>Message</label>
<textarea
value=${formData.message}
onInput=${e => updateField('message', e.target.value)}
class=${errors.message ? 'error' : ''}
></textarea>
${errors.message && html`<span class="error-text">${errors.message}</span>`}
</div>
<button type="submit" disabled=${submitting}>
${submitting ? 'Sending...' : 'Send Message'}
</button>
</form>
`;
});
registry.component('ProductCard', function({ productId }) {
const { useState, useEffect, html, bus } = window.fullfinity;
const [product, setProduct] = useState(null);
const [loading, setLoading] = useState(true);
const [quantity, setQuantity] = useState(1);
const [adding, setAdding] = useState(false);
// Fetch product data on mount
useEffect(() => {
async function fetchProduct() {
const response = await fetch(`/api/products/${productId}`);
const data = await response.json();
setProduct(data);
setLoading(false);
}
fetchProduct();
}, [productId]);
async function addToCart() {
setAdding(true);
try {
await fetch('/api/cart/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ product_id: productId, quantity })
});
// Notify other components
bus.emit('cart:updated');
} finally {
setAdding(false);
}
}
if (loading) {
return html`<div class="loading">Loading...</div>`;
}
return html`
<div class="product-card">
<img src=${product.image} alt=${product.name} />
<h3>${product.name}</h3>
<p class="price">$${product.price.toFixed(2)}</p>
<div class="quantity">
<button onClick=${() => setQuantity(q => Math.max(1, q - 1))}>-</button>
<span>${quantity}</span>
<button onClick=${() => setQuantity(q => q + 1)}>+</button>
</div>
<button
class="add-to-cart"
onClick=${addToCart}
disabled=${adding}
>
${adding ? 'Adding...' : 'Add to Cart'}
</button>
</div>
`;
});

The html function uses htm to parse JSX-like syntax in template literals:

const { html } = window.fullfinity;
// Basic elements
html`<div class="container">Hello</div>`
// Expressions
html`<span>${user.name}</span>`
// Event handlers
html`<button onClick=${handleClick}>Click</button>`
// Conditional rendering
html`${isVisible && html`<div>Visible!</div>`}`
// Lists
html`
<ul>
${items.map(item => html`<li key=${item.id}>${item.name}</li>`)}
</ul>
`
// Components inside components
html`
<div>
<${ChildComponent} prop="value" />
</div>
`
// Fragments
html`
<>
<div>First</div>
<div>Second</div>
</>
`
JSXhtm
classNameclass
htmlForfor
Self-closing requiredOptional
Build step requiredNo build step

Props are passed via data-props as JSON:

<!-- Simple props -->
<div data-component="Greeting" data-props='{"name": "World"}'></div>
<!-- With Jinja2 variables -->
<div
data-component="ProductCard"
data-props='{"productId": {{ product.id }}, "showPrice": true}'
></div>
<!-- Complex objects (use tojson filter) -->
<div
data-component="DataTable"
data-props='{{ table_config | tojson }}'
></div>
registry.component('Greeting', function(props) {
// Destructure props
const { name, greeting = 'Hello' } = props;
return html`<h1>${greeting}, ${name}!</h1>`;
});
// Or use default parameters
registry.component('Greeting', function({ name, greeting = 'Hello' }) {
return html`<h1>${greeting}, ${name}!</h1>`;
});

Use useEffect for lifecycle management:

registry.component('LiveData', function({ endpoint }) {
const { useState, useEffect, html } = window.fullfinity;
const [data, setData] = useState(null);
useEffect(() => {
// On mount: start polling
const interval = setInterval(async () => {
const response = await fetch(endpoint);
setData(await response.json());
}, 5000);
// Cleanup: stop polling on unmount
return () => clearInterval(interval);
}, [endpoint]); // Re-run if endpoint changes
return html`<pre>${JSON.stringify(data, null, 2)}</pre>`;
});

Components can render other components:

registry.component('Card', function({ title, children }) {
const { html } = window.fullfinity;
return html`
<div class="card">
<h3>${title}</h3>
<div class="card-body">${children}</div>
</div>
`;
});
registry.component('ProductList', function({ products }) {
const { html } = window.fullfinity;
const Card = registry.component('Card');
return html`
<div class="product-list">
${products.map(product => html`
<${Card} title=${product.name}>
<p>${product.description}</p>
<span class="price">$${product.price}</span>
<//>
`)}
</div>
`;
});

Wrap components in error boundaries:

registry.component('SafeComponent', function({ children }) {
const { useState, html } = window.fullfinity;
const [error, setError] = useState(null);
if (error) {
return html`<div class="error">Something went wrong: ${error.message}</div>`;
}
try {
return children;
} catch (e) {
setError(e);
return null;
}
});
// Good: Single responsibility
registry.component('AddToCartButton', function({ productId }) { ... });
registry.component('QuantitySelector', function({ value, onChange }) { ... });
// Bad: Too many responsibilities
registry.component('ProductEverything', function({ productId }) {
// Fetches, displays, adds to cart, reviews, etc.
});
// Reusable fetch hook
function useFetch(url) {
const { useState, useEffect } = window.fullfinity;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(url)
.then(r => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return { data, loading, error };
}
// Use in components
registry.component('UserProfile', function({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
// ...
});
// Good: Semantic, CSS-friendly
return html`<div class="product-card product-card--featured">...</div>`;
// Bad: Inline styles, non-semantic
return html`<div style="padding: 10px; border: 1px solid #ccc">...</div>`;
registry.component('DataWidget', function({ endpoint }) {
const { useState, useEffect, html } = window.fullfinity;
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetch(endpoint)
.then(r => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [endpoint]);
if (loading) return html`<div class="skeleton">Loading...</div>`;
if (error) return html`<div class="error">Failed to load</div>`;
return html`<div class="data">${JSON.stringify(data)}</div>`;
});

Recommended module structure:

my_module/
├── static/
│ └── src/
│ └── js/
│ └── components.js # All components for this module
├── models/
├── views/
└── manifest.json

For larger modules, you can split into multiple files and import:

components.js
import './product-card.js';
import './cart-widget.js';
import './checkout-form.js';

However, this requires your module JS files to be ES modules (type="module" in script tag, which is already set by the framework).