Skip to content

Actions

Kyoto has a way to simplify building dynamic UIs. For this purpose it has a module named "actions". Principle is very similar to component methods in traditional front-end frameworks. The main difference - all actions are executed on the server side, code placed on the server and client has just a thin communication layer. Even template rendering remains on the server side to avoid bringing render functionality to the client. Module uses approach similar to Laravel Livewire or Hotwired which sending HTML instead of JSON over the wire.

Installation

To use actions, you will need to prepare your project:

  • include a communication layer on resulting page with dynamics function
  • register an actions handler (provided by actions.Handler) with a specific route
  • register every dynamic component with actions.Register
...

// Register actions handler
mux.HandleFunc("/internal/actions/", actions.Handler(func(c *kyoto.Core) *template.Template {
    return template.Must(template.New("Actions").Funcs(render.FuncMap(c)).ParseGlob("*.html"))
}))
// Register dynamic components
actions.Register(
    ComponentFoo,
    ComponentBar,
    ComponentBaz(""),  // In case of wrapped core receiver, we need to call a wrapper
    ...
)

...
<html>
    <head>
    ...
    </head>
    <body>
    ...
    <!-- Include actions communication layer -->
    {{ dynamics }}
    </body>
</html>

Usage

Definition

To define an action for a component you can use actions.Define adapter. Also, you will need to include dynamic component attributes with componentattrs function into top-level HTML tag.

...

func ComponentFoo(core *kyoto.Core) {
    ...
    actions.Define(core, "Bar", func(args ...interface{}) {
        // Your action logic
    })
    actions.Define(core, "Baz", func(args ...interface{}) {
        // Your action logic
    })
}

...
{{ define "ComponentFoo" }}
<div {{ componentattrs . }} >
    ...
</div>
{{ end }}

Action call

Library provides many ways to make an action call. Most of them have shortcurts, implemented as template functions to simplify usage.

Direct

The most basic component action call. There are 2 ways to trigger a component action: with JS function Action(this, ...) or template function {{ action ... }}. In both cases additional arguments will be passed to action handler as ...interface{}.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }}>
    <div> Current state: {{ .Content }} </div>
    <!-- Example of call with shortcut -->
    <button onclick="{{ action `Bar` }}">Bar</button>
    <!-- Example of call with JS function -->
    <button onclick="Action(this, 'Baz')">Baz</button>
</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    lifecycle.Init(core, func() {
        core.State.Set("Content", "Foo")
    })
    actions.Define(core, "Bar", func(args ...interface{}) {
        core.State.Set("Content", "Bar")
    })
    actions.Define(core, "Baz", func(args ...interface{}) {
        core.State.Set("Content", "Baz")
    })
}

...

Cross-component

You can call other components' methods with different preffixes. $ preffix allows you to call methods of a parent component. To call a components' method by it's id, you can use : delimiter with such syntax <component-id>:<method-name>.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }}>
    <button onclick="{{ action `$Trigger` }}">Trigger parent components' ("Bar") action named "Trigger"</button>
    <button onclick="{{ action `Baz:Trigger` }}">Trigger action named "Trigger" on component with id "Baz"</button>
</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    // Nothing to do here
}

...
...

func ComponentBar(core *kyoto.Core) {
    lifecycle.Init(core, func() {
        core.Component("Foo", ComponentFoo)
    })
    actions.Define(core, "Trigger", func(args ...interface{}) {
        ...
    })
}

...
...

func ComponentBaz(core *kyoto.Core) {
    actions.Define(core, "Trigger", func(args ...interface{}) {
        ...
    })
}

...

Form submit

Thanks to actions, kyoto have a way to submit a form without page reloading. Form submission will be received in the component as an action. Instead of passing form values as arguments, library unpacks that data directly into the component by the name attribute. There are 2 ways to use this feature: with JS function FormSubmit(this, event) or template function {{ formsubmit }}.

{{ define "ComponentForm" }}
<form
  {{ componentattrs . }}
  action="#"
  method="POST"
  onsubmit="{{ formsubmit }}"
>
  <input name="Email" value="{{ .Email }}" type="email" />
  <button type="submit">Submit</button>
</form>
{{ end }}
...

func ComponentForm(core *kyoto.Core) {
    actions.Define(core, "Submit", func() {
        ...
    })
}

...

Trigger with onload

Library provides alternative ways to trigger an action. One of them is triggering on page load. This may be useful for components' lazy loading. This feature is implemented as ssa:onload HTML attribute and accepts just an action name.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }} ssa:onload="Bar">
    ...
</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    ...
    actions.Define(core, "Bar", func(args ...interface{}) {
        ...
    })
}

...

Trigger with poll

Another way to trigger an action is triggering with interval. Useful for components that must to be updated over time (f.e. charts, stats, etc). You can use this trigger with ssa:poll and ssa:poll.interval HTML attributes.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }} ssa:poll="Bar" ssa:poll.interval="1000">
    ...
</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    ...
    actions.Define(core, "Bar", func(args ...interface{}) {
        ...
    })
}

...

Trigger with intersection

You can use the ssa:onintersect HTML attribute to trigger an action on element intersection. This functionality was built on top of the browser's built-in IntersectionObserver.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }} ssa:onintersect="Bar">...</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    ...
    actions.Define(core, "Bar", func(args ...interface{}) {
        ...
    })
}

...

Binding

Not all operations needs to be done on server side. Some actions like input binding are better implemented on the client side to avoid delays and unnecessary server calls. That's why the library have a way to bind controls to component state.

Input binding

For input binding, the Kyoto library provides the bind template function. This function accepts one argument - the target component field name. Also, you can use Bind(this, 'FieldName') JS function if you would like to avoid shortcuts.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }}>
    <input oninput="{{ bind `FieldName` }}">
    ...
</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    lifecycle.Init(core, func() {
        core.State.Set("FieldName", "")
    })
}

...

Flow control

Control display on action

Because kyoto makes a roundtrip to the server every time an action is triggered on the page, there are cases where the page may not react immediately to a user event (like a click). That's why the library provides a way to easily control display attributes on action call. You can use ssa:oncall.display HTML attribute to control display during action call. At the end of an action the layout will be restored.

Don't forget to set a default display for loading elements like spinners and loaders.

{{ define "ComponentFoo" }}
<div {{ componentattrs . }}>
  <div ssa:oncall.display="block" style="display: none">Loading ...</div>
  <button onclick="{{ action 'Bar' }}">Load</button>
</div>
{{ end }}
...

func ComponentFoo(core *kyoto.Core) {
    ...
    actions.Define(core, "Bar", func(args ...interface{}) {
        ...
    })
}

...

Multi-stage UI update

You can push multiple component UI updates during single action call. Just call actions.Flush(core) to initiate an update.

...

func ComponentFoo(core *kyoto.Core) {
    lifecycle.Init(core, func() {
        core.State.Set("Status", "Not loaded")
    })
    actions.Define(core, "Bar", func(args ...interface{}) {
        // Update status UI
        c.Status = "Loading ..."
        actions.Flush(core)
        // Do some actions
        // ...
        // Update status UI again
        c.Status = "Loaded"
        actions.Flush(core)
    })
}

...
{{ define "ComponentFoo" }}
<div {{ componentattrs . }}>
  <div> Status: {{ .Status }} </div>
  <button onclick="{{ action 'Bar' }}">Bar</button>
</div>
{{ end }}

Rendering options

Mode

There are cases when morphdom may fail. The library then falls back to replace mode instead, which just replaces element's outerHTML. To force the library to use replace mode, you can use the ssa:render.mode HTML attribute.

{{ define "ComponentExample" }}
<div {{ componentattrs . }} ssa:render.mode="replace">
    ...
</div>
{{ end }}

Limitations and advices

  • Your component must to be JSON serializable. This is related to how the Actions feature works under the hood. Component state is stored directly in DOM and can be used by client-size operations like binding.
  • Avoid using of interface types in components with Actions. That way your component will become incompatible with JSON serialization process.
  • Avoid huge states. This will increase the total page size and will slow down Actions operations. You can avoid field JSON serialization with json:"-" if it's not needed. i.e in case of list/table data. We're trying to avoid storing rows of data in the state and using database directly instead.
  • When you're modifying state in the child component, state of the parent component is not updating. Be careful with dynamic components nesting.
Back to top