Components
Components are reusable, interactive UI elements built with Preact. They’re registered with the framework and can be mounted anywhere in your templates.
Creating a Component
Section titled “Creating a Component”Basic Structure
Section titled “Basic Structure”Create static/src/js/components.js in your module:
const { registry, html, useState } = window.fullfinity;
// Register a componentregistry.component('MyButton', function({ label, onClick }) { return html` <button class="my-button" onClick=${onClick}> ${label} </button> `;});Using in Templates
Section titled “Using in Templates”Mount the component in a Jinja2 template:
<div data-component="MyButton" data-props='{"label": "Click Me"}'></div>The framework automatically:
- Finds elements with
data-component - Parses
data-propsas JSON - Renders the component into the element
Component Examples
Section titled “Component Examples”Counter with State
Section titled “Counter with State”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> `;});Form with Validation
Section titled “Form with Validation”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> `;});Product Card with API Call
Section titled “Product Card with API Call”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 Tagged Template
Section titled “The html Tagged Template”The html function uses htm to parse JSX-like syntax in template literals:
const { html } = window.fullfinity;
// Basic elementshtml`<div class="container">Hello</div>`
// Expressionshtml`<span>${user.name}</span>`
// Event handlershtml`<button onClick=${handleClick}>Click</button>`
// Conditional renderinghtml`${isVisible && html`<div>Visible!</div>`}`
// Listshtml` <ul> ${items.map(item => html`<li key=${item.id}>${item.name}</li>`)} </ul>`
// Components inside componentshtml` <div> <${ChildComponent} prop="value" /> </div>`
// Fragmentshtml` <> <div>First</div> <div>Second</div> </>`Differences from JSX
Section titled “Differences from JSX”| JSX | htm |
|---|---|
className | class |
htmlFor | for |
| Self-closing required | Optional |
| Build step required | No build step |
Passing Props from Templates
Section titled “Passing Props from Templates”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>Accessing Props in Components
Section titled “Accessing Props in Components”registry.component('Greeting', function(props) { // Destructure props const { name, greeting = 'Hello' } = props;
return html`<h1>${greeting}, ${name}!</h1>`;});
// Or use default parametersregistry.component('Greeting', function({ name, greeting = 'Hello' }) { return html`<h1>${greeting}, ${name}!</h1>`;});Component Lifecycle
Section titled “Component Lifecycle”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>`;});Nested Components
Section titled “Nested Components”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> `;});Error Handling
Section titled “Error Handling”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; }});Best Practices
Section titled “Best Practices”1. Keep Components Focused
Section titled “1. Keep Components Focused”// Good: Single responsibilityregistry.component('AddToCartButton', function({ productId }) { ... });registry.component('QuantitySelector', function({ value, onChange }) { ... });
// Bad: Too many responsibilitiesregistry.component('ProductEverything', function({ productId }) { // Fetches, displays, adds to cart, reviews, etc.});2. Extract Reusable Logic
Section titled “2. Extract Reusable Logic”// Reusable fetch hookfunction 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 componentsregistry.component('UserProfile', function({ userId }) { const { data: user, loading, error } = useFetch(`/api/users/${userId}`); // ...});3. Use Semantic Class Names
Section titled “3. Use Semantic Class Names”// Good: Semantic, CSS-friendlyreturn html`<div class="product-card product-card--featured">...</div>`;
// Bad: Inline styles, non-semanticreturn html`<div style="padding: 10px; border: 1px solid #ccc">...</div>`;4. Handle Loading States
Section titled “4. Handle Loading States”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>`;});File Structure
Section titled “File Structure”Recommended module structure:
my_module/├── static/│ └── src/│ └── js/│ └── components.js # All components for this module├── models/├── views/└── manifest.jsonFor larger modules, you can split into multiple files and import:
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).