Tutorial

バックエンドとフロントエンドを通信させる

Marionetteでは、Goのハンドラが状態を更新し、htmxが必要なHTML断片だけを受け取って画面に差し替えます。 この回では、メッセージ投稿フォームを作りながら通信の流れを学びます。

Step 1

通信の全体像を理解する

基本の流れは4つです。

  1. app.Page が最初の画面全体を返す
  2. ActionForm がフォーム送信をhtmxリクエストにする
  3. app.Action がバックエンドで入力を受け取り、状態を更新する
  4. Region の中身だけが新しいHTMLで差し替わる

Step 2

バックエンドに状態を持たせる

app.SetGlobal で全ユーザー共有のアプリケーション状態を初期化し、ハンドラ内では ctx.GetGlobalctx.SetGlobal でその共有 state を読み書きします。

これは小さな共有デモには便利ですが、複数ユーザー前提の業務アプリでは、業務データを App global state に置かず repository の背後に置くのが基本形です。 複数ユーザーのタスク管理サンプル は、「global state を使わない業務アプリの基本形」として参照できます。

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

Step 3

最初の画面を作る

app.Page は通常のGETページです。ここではフォームと、あとで差し替える Region を同じ画面に配置します。

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

Step 4

フォームからActionへ送る

ActionForm はHTMLの form にhtmx属性を付けます。Target には差し替えたい要素のCSSセレクタを指定します。

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

Step 5

Actionで入力を受け取る

app.Action はPOSTされたフォーム値を読み取り、状態を更新し、差し替え用のHTML断片を返します。

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

全体のコード

main.go を次の内容に置き換えます。

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

起動して通信を確認する

go run .

ブラウザで http://127.0.0.1:8080 を開き、メッセージを投稿します。ページ全体ではなく、#messages の中だけが更新されます。

Step 8

ここで習得したこと

  • Page は初期表示、Action はイベント処理を担当する
  • ActionForm がフロントエンドからバックエンドへの通信を作る
  • TargetRegion で更新範囲を限定する
  • Actionは画面全体ではなく、差し替えたいHTML断片を返せる

この仕組みは分析ダッシュボードにも使えます。DataQueryState をURLクエリまたはサーバー状態で共有し、 例えば地域チャートをクリックしたら同じ状態でチャートとテーブルを再描画すると、全ウィジェットを一括で絞り込めます。

次のステップ: データアプリ構築手順

UI部品を増やしたい場合は Components Gallery を確認してください。