Skip to content

ocomsoft/HxComponents

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

32 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HTMX Generic Component Registry

A type-safe, reusable Go library pattern for building dynamic HTMX components with minimal boilerplate.

Overview

The HTMX Generic Component Registry eliminates repetitive handler code by providing a clean, type-safe abstraction for:

  • Form parsing into typed structs using generics
  • HTMX request headers (HX-Boosted, HX-Request, HX-Current-URL, etc.)
  • HTMX response headers (HX-Redirect, HX-Trigger, HX-Reswap, etc.)
  • Dynamic component routing via centralized registry
  • Type-safe rendering with templ templates

Features

  • Type-safe - Uses Go 1.23+ generics for compile-time verification
  • Zero boilerplate - Automatic form parsing and header handling
  • Interface-based - Optional HTMX headers via clean interfaces
  • Framework agnostic - Works with chi, gorilla/mux, net/http
  • Composable - Mix and match header interfaces as needed
  • Dual-use components - Same component works for HTMX requests AND server-side rendering
  • Context-aware - Full context.Context support for database queries and cancellation
  • Debug mode - Built-in debugging headers for development
  • Thread-safe - Safe for concurrent use

Documentation

Quick Links

Why Package-Level Register Function?

You might wonder why we use:

components.Register[*MyComponent](registry, "name")

Instead of a method like:

registry.Register[*MyComponent]("name")  // ❌ Not possible in Go

Answer: Go methods cannot have type parameters. This is a fundamental limitation of Go generics (as of Go 1.23).

The Go team made this design decision because:

  1. Method dispatch complexity - Type parameters on methods would complicate dynamic dispatch
  2. Interface implementation - Would make it unclear how interfaces with generic methods work
  3. Simplicity - Keeps the type system simpler and more predictable

So we use a package-level generic function instead, which is the idiomatic Go approach for generic operations that need access to a struct.

Alternative approaches we considered:

  • Builder pattern: registry.Component("name").As[*Type]() - Still needs generic method ❌
  • Reflection-only: registry.Register("name", &Component{}) - Loses type safety ❌
  • Package function: components.Register[*Type](registry, "name") - Works! ✅

Further reading:

Quick Start

Installation

go get github.com/ocomsoft/HxComponents

Basic Example

1. Define your component struct that implements templ.Component:

package mycomponent

import (
    "context"
    "io"
)

type SearchComponent struct {
    Query string `form:"q"`
    Limit int    `form:"limit"`
}

// Render implements templ.Component interface
func (c *SearchComponent) Render(ctx context.Context, w io.Writer) error {
    return Search(*c).Render(ctx, w)
}

2. Create a templ template:

package mycomponent

templ Search(data SearchComponent) {
    <div>
        <p>Query: { data.Query }</p>
        <p>Limit: { fmt.Sprint(data.Limit) }</p>
    </div>
}

3. Register and serve:

package main

import (
    "github.com/go-chi/chi/v5"
    "github.com/ocomsoft/HxComponents/components"
    "myapp/mycomponent"
)

func main() {
    registry := components.NewRegistry()
    components.Register[*mycomponent.SearchComponent](registry, "search")

    router := chi.NewRouter()
    router.Get("/component/*", registry.Handler)
    router.Post("/component/*", registry.Handler)

    http.ListenAndServe(":8080", router)
}

4. Use in HTML (HTMX):

<form hx-post="/component/search" hx-target="#results">
    <input type="text" name="q" />
    <input type="number" name="limit" value="10" />
    <button>Search</button>
</form>
<div id="results"></div>

5. Or use in templ templates (server-side):

templ MyPage() {
    <h1>Search Results</h1>
    // Use the component directly - it implements templ.Component!
    @(&mycomponent.SearchComponent{Query: "golang", Limit: 10})
}

The same component works both ways:

  • As an HTMX component that handles dynamic POST/GET requests
  • As a templ component that can be embedded in other templates

6. Or use with GET for initial state:

<!-- Load component with query parameters -->
<div hx-get="/component/search?q=golang&limit=5" hx-trigger="load" hx-target="this"></div>

<!-- Or via a link/button -->
<button hx-get="/component/search?q=htmx&limit=10" hx-target="#results">
    Load Search Results
</button>

Router Integration

The registry is framework-agnostic and works with any Go HTTP router or the standard library.

Using with chi (Wildcard Pattern)

The simplest approach uses the Handler method with wildcard routing:

package main

import (
    "github.com/go-chi/chi/v5"
    "github.com/ocomsoft/HxComponents/components"
)

func main() {
    registry := components.NewRegistry()
    components.Register(registry, "search", mycomponent.Search)
    components.Register(registry, "login", mycomponent.Login)

    router := chi.NewRouter()

    // Mount all components with wildcard pattern
    router.Get("/component/*", registry.Handler)
    router.Post("/component/*", registry.Handler)

    http.ListenAndServe(":8080", router)
}

The Handler method extracts the component name from the last segment of the URL path:

  • /component/search → component name: search
  • /api/login → component name: login
  • /component/profile → component name: profile

Using with chi (Specific URLs)

For explicit control over each component URL, use HandlerFor():

router := chi.NewRouter()

// Mount components at specific URLs
router.Get("/search", registry.HandlerFor("search"))
router.Post("/search", registry.HandlerFor("search"))

router.Get("/login", registry.HandlerFor("login"))
router.Post("/login", registry.HandlerFor("login"))

Using with gorilla/mux

package main

import (
    "github.com/gorilla/mux"
    "github.com/ocomsoft/HxComponents/components"
)

func main() {
    registry := components.NewRegistry()
    components.Register(registry, "search", mycomponent.Search)
    components.Register(registry, "login", mycomponent.Login)

    router := mux.NewRouter()

    // Option 1: Wildcard pattern (extracts component name from URL)
    router.PathPrefix("/component/").HandlerFunc(registry.Handler).Methods("GET", "POST")

    // Option 2: Specific URLs (explicit component names)
    router.HandleFunc("/search", registry.HandlerFor("search")).Methods("GET", "POST")
    router.HandleFunc("/login", registry.HandlerFor("login")).Methods("GET", "POST")

    http.ListenAndServe(":8080", router)
}

Using with net/http (Standard Library)

No router needed - use the standard library directly:

package main

import (
    "net/http"
    "github.com/ocomsoft/HxComponents/components"
)

func main() {
    registry := components.NewRegistry()
    components.Register(registry, "search", mycomponent.Search)
    components.Register(registry, "login", mycomponent.Login)

    // Option 1: Wildcard pattern (extracts component name from URL)
    http.HandleFunc("/component/", registry.Handler)

    // Option 2: Specific URLs (explicit component names)
    http.HandleFunc("/search", registry.HandlerFor("search"))
    http.HandleFunc("/login", registry.HandlerFor("login"))

    http.ListenAndServe(":8080", nil)
}

Mixing Components with Custom Handlers

You can easily mix component handlers with your custom route handlers:

router := chi.NewRouter()

// Custom handlers
router.Get("/", homePageHandler)
router.Get("/about", aboutPageHandler)

// Component handlers with wildcard
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)

// Or specific component URLs
router.Get("/search", registry.HandlerFor("search"))
router.Post("/search", registry.HandlerFor("search"))

HTMX Request Headers

Capture HTMX request headers and HTTP method by implementing optional interfaces:

type SearchComponent struct {
    Query      string `form:"q"`
    IsBoosted  bool   `json:"-"`
    CurrentURL string `json:"-"`
    Method     string `json:"-"` // "GET" or "POST"
}

func (s *SearchComponent) SetHxBoosted(v bool)      { s.IsBoosted = v }
func (s *SearchComponent) SetHxCurrentURL(v string) { s.CurrentURL = v }
func (s *SearchComponent) SetHttpMethod(v string)   { s.Method = v }

The HttpMethod interface is useful for varying component behavior based on GET vs POST:

func (s *SearchComponent) Process() error {
    if s.Method == "GET" {
        // Load default search results
        s.Query = s.Query // Keep query from URL params
    } else {
        // POST - user submitted form
        // Validate input, log search, etc.
    }
    return nil
}

Available Request Interfaces:

Interface Header/Source Type
HxBoosted HX-Boosted bool
HxRequest HX-Request bool
HxCurrentURL HX-Current-URL string
HxPrompt HX-Prompt string
HxTarget HX-Target string
HxTrigger HX-Trigger string
HxTriggerName HX-Trigger-Name string
HttpMethod HTTP Method (GET/POST) string

HTMX Response Headers

Set HTMX response headers by implementing getter interfaces:

type LoginComponent struct {
    Username   string `form:"username"`
    Password   string `form:"password"`
    RedirectTo string `json:"-"`
}

func (f *LoginComponent) GetHxRedirect() string {
    return f.RedirectTo
}

Available Response Interfaces:

Interface Header Type
HxLocationResponse HX-Location string
HxPushUrlResponse HX-Push-Url string
HxRedirectResponse HX-Redirect string
HxRefreshResponse HX-Refresh bool
HxReplaceUrlResponse HX-Replace-Url string
HxReswapResponse HX-Reswap string
HxRetargetResponse HX-Retarget string
HxReselectResponse HX-Reselect string
HxTriggerResponse HX-Trigger string
HxTriggerAfterSettleResponse HX-Trigger-After-Settle string
HxTriggerAfterSwapResponse HX-Trigger-After-Swap string

GET vs POST Requests

The registry supports both GET and POST requests for maximum flexibility:

POST Requests (Standard Pattern)

POST is the standard HTMX pattern for form submissions:

<form hx-post="/component/search" hx-target="#results">
    <input type="text" name="q" value="htmx" />
    <button>Search</button>
</form>

Form data is sent in the request body and parsed into the component struct.

GET Requests (Initial State)

GET requests are useful for loading components with initial state or query parameters:

<!-- Load on page load -->
<div hx-get="/component/search?q=golang&limit=5"
     hx-trigger="load"
     hx-target="this">
</div>

<!-- Load on click -->
<button hx-get="/component/search?q=htmx&limit=10"
        hx-target="#results">
    Load Popular Searches
</button>

<!-- Preload with hx-boost -->
<a href="/component/search?q=go&limit=20"
   hx-boost="true"
   hx-target="#results">
    Go Results
</a>

Query parameters are parsed into the component struct, just like POST form data.

Use Cases for GET:

  • Loading components with default/initial values
  • Deep-linking to specific component states
  • Shareable URLs with query parameters
  • Server-side rendering of initial state
  • Progressive enhancement patterns

Component Processing

Components can implement the Processor interface to perform business logic, validation, or data transformation after form decoding but before rendering.

The Processor Interface

type Processor interface {
    Process() error
}

The registry automatically calls Process() if your component implements this interface:

type LoginComponent struct {
    Username   string `form:"username"`
    Password   string `form:"password"`
    RedirectTo string `json:"-"`
    Error      string `json:"-"`
}

// Implement Processor interface
func (f *LoginComponent) Process() error {
    if f.Username == "demo" && f.Password == "password" {
        f.RedirectTo = "/dashboard"  // This will trigger HX-Redirect
        return nil
    }
    f.Error = "Invalid credentials"
    return nil
}

// Implement response header interface
func (f *LoginComponent) GetHxRedirect() string {
    return f.RedirectTo
}

Processing Flow:

  1. Form data decoded into struct
  2. Request headers applied (HX-Boosted, HX-Request, etc.)
  3. Process() called (if interface implemented)
  4. Response headers applied (HX-Redirect, HX-Trigger, etc.)
  5. Component rendered

When to Use:

  • Form validation
  • Authentication/authorization
  • Database operations
  • Setting conditional response headers
  • Business logic that affects rendering

Error Handling:

  • Return error only for unexpected system failures
  • Store validation errors in struct fields for rendering
  • Example: f.Error = "Invalid input" instead of return err

Advanced Examples

Login Component with Redirect

This example is now simplified with the Processor interface:

type LoginComponent struct {
    Username   string `form:"username"`
    Password   string `form:"password"`
    RedirectTo string `json:"-"`
    Error      string `json:"-"`
}

// Processor interface - called automatically by registry
func (f *LoginComponent) Process() error {
    if f.Username == "demo" && f.Password == "password" {
        f.RedirectTo = "/dashboard"
        return nil
    }
    f.Error = "Invalid credentials"
    return nil
}

// Response header interface
func (f *LoginComponent) GetHxRedirect() string {
    return f.RedirectTo
}

Register (simplified):

components.Register(registry, "login", Login)

The registry automatically calls Process() before rendering!

Profile Update with Array Support

type ProfileComponent struct {
    Name  string   `form:"name"`
    Email string   `form:"email"`
    Tags  []string `form:"tags"`
}

HTML:

<form hx-post="/component/profile" hx-target="#result">
    <input name="name" value="John" />
    <input name="email" value="john@example.com" />
    <input name="tags" value="developer" />
    <input name="tags" value="golang" />
    <button>Update</button>
</form>

Running the Example

The examples/ directory contains a complete demo application:

cd examples
go run main.go

Then open http://localhost:8080 in your browser.

The demo includes:

  • Search Component - Demonstrates request header capture
  • Login Component - Demonstrates response headers (redirects)
  • Profile Component - Demonstrates complex form data with arrays

Architecture

Registry Flow

1. HTTP POST /component/{name}
2. Registry finds component entry
3. Parse form data into typed struct
4. Apply HTMX request headers (if interfaces implemented)
5. Execute component logic (optional)
6. Apply HTMX response headers (if interfaces implemented)
7. Render templ component
8. Return HTML response

Type Safety

The registry uses Go generics to maintain type safety:

func Register[T any](r *Registry, name string, render func(T) templ.Component)

This ensures:

  • ✅ Compile-time type checking
  • ✅ No runtime type assertion errors
  • ✅ IDE autocomplete and refactoring support

API Reference

Registry Methods

NewRegistry() *Registry

Creates a new component registry.

Register[T templ.Component](r *Registry, name string)

Registers a component type that implements templ.Component.

Parameters:

  • T - Component type (must be a pointer type that implements templ.Component)
  • name - Component name used to identify the component

Example:

components.Register[*mycomponent.SearchComponent](registry, "search")

Requirements:

  • The component type must implement templ.Component interface
  • The component must have a Render(ctx context.Context, w io.Writer) error method

Handler(w http.ResponseWriter, req *http.Request)

Extracts the component name from the URL path and renders the component. The component name is extracted from the last segment of the URL path after the last slash. This allows for wildcard routing patterns.

Example:

// With chi
router.Get("/component/*", registry.Handler)
router.Post("/component/*", registry.Handler)

// With gorilla/mux
router.PathPrefix("/component/").HandlerFunc(registry.Handler).Methods("GET", "POST")

// With net/http
http.HandleFunc("/component/", registry.Handler)

URL to Component Name Mapping:

  • /component/searchsearch
  • /api/loginlogin
  • /component/profileprofile

HandlerFor(componentName string) http.HandlerFunc

Returns an http.HandlerFunc for rendering a specific component. Use this when you want explicit control over component URLs.

Parameters:

  • componentName - The name of the registered component

Example:

http.HandleFunc("/search", registry.HandlerFor("search"))
router.Get("/search", registry.HandlerFor("search"))

SetErrorHandler(handler ErrorHandler)

Sets a custom error handler for rendering error responses.

Example:

registry.SetErrorHandler(func(w http.ResponseWriter, req *http.Request, title string, message string, code int) {
    // Custom error rendering with your own template
    w.Header().Set("Content-Type", "text/html")
    w.WriteHeader(code)
    MyErrorTemplate(title, message, code).Render(req.Context(), w)
})

Logging

The registry uses Go's standard log/slog for structured logging. Configure your logger before starting the server:

import "log/slog"

// Set log level to debug to see component rendering logs
slog.SetLogLoggerLevel(slog.LevelDebug)

// Or use a custom handler
handler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
    Level: slog.LevelInfo,
})
slog.SetDefault(slog.New(handler))

Logged Events:

  • Debug - Component rendering started and completed
  • Warn - Method not allowed, component not found
  • Error - Form parse/decode errors, process errors, render errors

Error Handling

The registry provides a customizable error handler for rendering error responses. By default, it uses the built-in ErrorComponent template.

Default Error Component:

ErrorComponent(title string, message string, code int)

Custom Error Handler:

type ErrorHandler func(w http.ResponseWriter, req *http.Request, title string, message string, code int)

registry := components.NewRegistry()
registry.SetErrorHandler(myCustomErrorHandler)

Best Practices

1. Use Descriptive Component Names

// Good
components.Register(registry, "user-search", SearchComponent)
components.Register(registry, "profile-edit", ProfileComponent)

// Avoid
components.Register(registry, "comp1", SearchComponent)

2. Keep Component Logic Separate

// Good - logic in method
func (f *LoginComponent) ProcessLogin() error { ... }

components.Register(registry, "login", func(data LoginComponent) templ.Component {
    data.ProcessLogin()
    return LoginComponent(data)
})

// Avoid - logic in template

3. Use JSON Tags to Hide Internal Fields

type MyForm struct {
    UserInput  string `form:"input"`
    RedirectTo string `json:"-"` // Won't be serialized
    IsBoosted  bool   `json:"-"` // Internal state
}

4. Implement Only Needed Interfaces

// Good - only implement what you need
type SimpleForm struct {
    Query string `form:"q"`
}

// Avoid - implementing unused interfaces

Troubleshooting

Form Fields Not Parsing

Problem: Struct fields remain empty after form submission.

Solution: Ensure form tags match HTML input names exactly:

type Form struct {
    Email string `form:"email"` // Must match <input name="email">
}

Headers Not Applied

Problem: HTMX headers not being captured or set.

Solution: Verify interface implementation:

// Implement pointer receiver
func (f *MyForm) SetHxBoosted(v bool) {
    f.IsBoosted = v
}

Component Not Found

Problem: 404 error when posting to component.

Solution: Check component name matches registration:

components.Register(registry, "my-component", ...)
// POST to: /component/my-component

Dependencies

  • templ - Type-safe Go templating
  • chi - Lightweight router (optional)
  • form - Form decoding

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT License - See LICENSE file for details.

Learn More


Built with ❤️ for the Go + HTMX community

About

HTMX Components using Go templ.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published