Tutorial

Connect the Backend and Frontend

In Marionette, Go handlers update state, then htmx swaps the returned HTML fragment into the page. This lesson builds a small message form so you can see that request flow.

Step 1

Understand the Request Flow

The basic flow has four parts.

  1. app.Page returns the initial full page
  2. ActionForm turns form submission into an htmx request
  3. app.Action receives input and updates backend state
  4. Region receives the returned HTML fragment

Step 2

Store State on the Backend

Use app.SetGlobal to initialize app-wide state shared by all users. Inside handlers, use ctx.GetGlobal and ctx.SetGlobal for that shared state.

This is useful for tiny shared demos, but a business application with multiple users should usually keep domain data behind a repository instead of storing it in app global state. See the multi-user task sample for the basic shape of a business app that does not use global state.

app := mb.New()
app.SetGlobal("messages", []string{})

Step 3

Render the Initial Page

app.Page handles a normal GET page. This page contains both the form and the Region that will be swapped later.

app.Page("/", func(ctx *mb.Context) mf.Node {
    return page(ctx)
})

Step 4

Send the Form to an Action

ActionForm renders a form with htmx attributes. Target points to the element that should receive the response.

mf.ActionForm(mf.ActionFormProps{
    Action: "/messages/create",
    Target: "#messages",
    Swap:   "innerHTML",
})

Step 5

Receive Input in an Action

app.Action reads the submitted value, updates state, and returns the HTML fragment for the target region.

app.Action("messages/create", func(ctx *mb.Context) mf.Node {
    text := strings.TrimSpace(ctx.FormValue("message"))
    if text != "" {
        ctx.UpdateGlobal("messages", func(old any) any {
            messages := cloneMessages(old)
            return append([]string{text}, messages...)
        })
    }
    messages := ctx.GetGlobalSnapshot("messages", cloneMessages).([]string)
    return messageList(messages)
})

Step 6

Full Code

Replace main.go with this version.

package main

import (
    "strings"

    mb "github.com/YoshihideShirai/marionette/backend"
    mf "github.com/YoshihideShirai/marionette/frontend"
)

func main() {
    app := mb.New()
    app.SetGlobal("messages", []string{})

    app.Page("/", func(ctx *mb.Context) mf.Node {
        return page(ctx)
    })

    app.Action("messages/create", func(ctx *mb.Context) mf.Node {
        text := strings.TrimSpace(ctx.FormValue("message"))
        if text != "" {
            ctx.UpdateGlobal("messages", func(old any) any {
                messages := cloneMessages(old)
                return append([]string{text}, messages...)
            })
        }
        messages := ctx.GetGlobalSnapshot("messages", cloneMessages).([]string)
        return messageList(messages)
    })

    if err := app.Run("127.0.0.1:8080"); err != nil {
        panic(err)
    }
}

func page(ctx *mb.Context) mf.Node {
    messages := ctx.GetGlobalSnapshot("messages", cloneMessages).([]string)

    return mf.Container(mf.ContainerProps{
        MaxWidth: "2xl",
        Centered: true,
    },
        mf.Stack(mf.StackProps{Direction: "column", Gap: "5"},
            mf.PageHeader(mf.PageHeaderProps{
                Title:       "Message Board",
                Description: "A small app that updates one region through htmx.",
            }),
            mf.ActionForm(mf.ActionFormProps{
                Action: "/messages/create",
                Target: "#messages",
                Swap:   "innerHTML",
                Props:  mf.ComponentProps{Class: "space-y-3"},
            },
                mf.FormRow(mf.FormRowProps{
                    ID:       "message",
                    Label:    "Message",
                    Required: true,
                    Control: mf.TextField(mf.TextFieldProps{
                        ID:          "message",
                        Name:        "message",
                        Placeholder: "Write a message",
                        Required:    true,
                    }),
                }),
                mf.SubmitButton("Post", mf.ComponentProps{}),
            ),
            mf.Region(mf.RegionProps{ID: "messages"}, messageList(messages)),
        ),
    )
}

func cloneMessages(old any) any {
    messages, _ := old.([]string)
    return append([]string(nil), messages...)
}

func messageList(messages []string) mf.Node {
    if len(messages) == 0 {
        return mf.EmptyState(mf.EmptyStateProps{
            Title:       "No messages yet",
            Description: "Submit the form to render this region again.",
        })
    }

    rows := make([]mf.TableComponentRow, 0, len(messages))
    for i, message := range messages {
        rows = append(rows, mf.TableRowValues(i+1, message))
    }

    return mf.Table(mf.TableProps{
        Columns: []mf.TableColumn{{Label: "No."}, {Label: "Message"}},
        Rows:    rows,
    })
}

Step 7

Run and Watch the Swap

go run .

Open http://127.0.0.1:8080 and submit a message. Only the content inside #messages is updated.

Step 8

What You Learned

  • Page handles initial rendering, while Action handles events
  • ActionForm creates frontend-to-backend communication
  • Target and Region limit what gets updated
  • An action can return a fragment instead of a full page

The same flow also works for analytics screens: keep a shared DataQueryState in query/server state, then when a user clicks a region in a chart, re-render both the chart and related tables with that state so all widgets are filtered together.

Next step: Data App Build Flow.

When you want to add more UI pieces, browse the Components Gallery.