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.
app.Pagereturns the initial full pageActionFormturns form submission into an htmx requestapp.Actionreceives input and updates backend stateRegionreceives 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
Pagehandles initial rendering, whileActionhandles eventsActionFormcreates frontend-to-backend communicationTargetandRegionlimit 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.