Country Selector
Component
Here we build the first interactive piece of the app: a country search.
Plan:
- Define a
countrySelectorcomponent with a dynamic container - Listen to input events from the search field
- Render matching countries into the dynamic container
This step introduces Door plus input and click hooks from Events.
./country_selector.gox
package main
import (
"context"
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
type countrySelector struct {
// dynamic container for search results
options *doors.Door
}
// component main render function
elem (l countrySelector) Main() {
<h3>Select Country</h3>
<input
// attach input listener
(doors.AInput{
On: func(ctx context.Context, r doors.RequestInput) bool {
// read the current input value
input := r.Event().Value
// update dynamic container with the new results
l.options.Update(ctx, l.results(input))
return false
},
})
type="search"
placeholder="Country"
autocomplete="off"/>
~// render dynamic container
~(l.options)
}
// here a regular function is more convenient than
// the elem primitive
func (l countrySelector) results(input string) gox.Elem {
if len(input) == 0 {
return nil
}
if len(input) < 2 {
return <p>
<mark>Type at least two letters to search</mark>
</p>
}
results, _ := driver.Locations.CountriesSearch(input)
if len(results) == 0 {
return <p>
<i>nothing found</i>
</p>
}
return <ul>
~(for _, place := range results {
<li>
~(place.Name)
</li>
})
</ul>
}
And render it on the page:
Render
./app.gox
elem (a App) Main() {
<!doctype html>
<html lang="en">
<head>
~/* head content */
</head>
<body>
<main class="container">
~// our new component
~countrySelector{
options: new(doors.Door),
}
</main>
</body>
</html>
}
At this point, typing in the input triggers a request, and the search results area is re-rendered with the new matches.
Result:

Debounce
This works, but sending a request on every keystroke is wasteful.
Add debounce with the Scopes API:
See Scopes for the full model.
<input
(doors.AInput{
// use ScopeOnly* helper to create a basic scope setup:
// wait 300 milliseconds after the last input before sending
// but not more than 600 milliseconds since the first
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
placeholder="Country"
autocomplete="off"/>
The Scopes API controls what happens when events overlap. Scopes run on the client, before the backend request begins, so they are the right tool for interaction policy like debouncing or preventing double-clicks.
Indication
The next problem is feedback. Searching is fast locally, but real responses are not instant, so the user should see that something is happening.
PicoCSS already has an aria-busy pattern for this. Doors can toggle it through the Indication API:
See Indication for the indicator helpers and selector variants.
elem (l countrySelector) Main() {
~// add loader element target
<h3>Select Country <span id="search-loader"></span></h3>
<input
(doors.AInput{
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
// use IndicatorOnly* helper to create a basic indication setup:
// apply attribute aria-busy="true" to the element #search-loader
Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
placeholder="Country"
autocomplete="off"/>
~(l.options)
}
Debounce and indication together (simulated latency):

The indication clears after all hook-triggered changes apply on the client.
State
Country and city selection can use the dynamic container API alone, without any reactive state.
However, in multi-step forms and more complex UIs, that lower-level approach spreads logic across handlers and makes the flow harder to read and debug.
A single source of truth makes the component easier to reason about.
For
Source, derived values, and subscriptions, see State.
Add State
Add a field for the selected country:
type countrySelector struct {
options *doors.Door
// reactive state source
selected doors.Source[driver.Place]
}
Now it makes sense to create the selector through a constructor:
func CountrySelector() gox.Comp {
return countrySelector{
options: new(doors.Door),
selected: doors.NewSource(driver.Place{}),
}
}
Also update the page template:
app.gox
<main class="container">
~(CountrySelector())
</main>
Update
Now update the selected state when the user clicks a search result:
func (l countrySelector) results(input string) gox.Elem {
/* ... */
// create shared scope
scope := doors.ScopeOnlyBlocking()
return <ul>
~(for _, place := range results {
<li>
<a
// attach click
(doors.AClick{
// use shared scope on all options
Scope: scope,
// prevent link default behavior
PreventDefault: true,
On: func(ctx context.Context, _ doors.RequestPointer) bool {
// update state with the selected country
l.selected.Update(ctx, place)
// done, remove hook
return true
},
})
href="#">
~(place.Name)
</a>
</li>
})
</ul>
}
We use the same scope set for all search options, so all clicks pass through one pipeline and only one can be in flight at a time. Strictly speaking, it will not change much here, but it is still a good practice. It avoids unnecessary requests and indication, which gives the UI a more polished feel.
Subscribe
Then subscribe the main render function to state changes:
elem (l countrySelector) Main() {
~// subscribe region to state changes, provide on change handler
~// that will return element to render
~(doors.Sub(l.selected, elem(p driver.Place) {
~(if p.IsValid() {
<h3>Country: <b>~(p.Name)</b></h3>
~// clear country selection
<button
(doors.AClick{
// indicate on the button
Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
// block repeated clicks
Scope: doors.ScopeOnlyBlocking(),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
// clear the selection
l.selected.Update(ctx, driver.Place{})
// hook is done, remove it
return true
},
})
class="secondary">
Change
</button>
} else {
<h3>Select Country <span id="search-loader"></span></h3>
<input
(doors.AInput{
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
// use IndicatorOnly* helper to create basic indication setup:
// apply attribute aria-busy="true" to the element #search-loader
Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
// keep hook active
return false
},
})
type="search"
placeholder="Country"
autocomplete="off"/>
~(l.options)
})
}))
}
This version already works, but the Main function is starting to do too much.
Refactor
doors.Sub can itself be rendered as a component, so we can make Main() smaller:
func (l countrySelector) Main() gox.Elem {
return doors.Sub(l.selected, func(p driver.Place) gox.Elem {
if p.IsValid() {
return l.place(p)
}
return l.input()
}).Main()
}
Then move each state into its own view:
elem (l countrySelector) place(p driver.Place) {
<h3>Country: <b>~(p.Name)</b></h3>
<button
(doors.AClick{
Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
Scope: doors.ScopeOnlyBlocking(),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.selected.Update(ctx, driver.Place{})
return true
},
})
class="secondary">
Change
</button>
}
elem (l countrySelector) input() {
<h3>Select Country <span id="search-loader"></span></h3>
<input
(doors.AInput{
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
placeholder="Country"
autocomplete="off"/>
~(l.options)
}
Let's see how selection works with reactive state:

Search results were not cleared. That makes sense: we never cleared them.
elem (l countrySelector) input() {
~/* ... */
~{
l.options.Clear(ctx)
}
~(l.options)
}
A dynamic container has its own lifecycle. When we render the selected country instead of the search results, we just unmount it, so we need to clear it before rendering again.
Result:

Next: Location Selector
Code
country_selector.gox
package main
import (
"context"
"time"
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
"github.com/doors-dev/tutorial/driver"
)
func CountrySelector() gox.Comp {
return countrySelector{
options: new(doors.Door),
selected: doors.NewSource(driver.Place{}),
}
}
type countrySelector struct {
options *doors.Door
selected doors.Source[driver.Place]
}
func (l countrySelector) Main() gox.Elem {
return doors.Sub(l.selected, func(p driver.Place) gox.Elem {
if p.IsValid() {
return l.place(p)
}
return l.input()
}).Main()
}
elem (l countrySelector) place(p driver.Place) {
<h3>Country: <b>~(p.Name)</b></h3>
<button
(doors.AClick{
Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
Scope: doors.ScopeOnlyBlocking(),
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.selected.Update(ctx, driver.Place{})
return true
},
})
class="secondary">
Change
</button>
}
elem (l countrySelector) input() {
<h3>Select Country <span id="search-loader"></span></h3>
<input
(doors.AInput{
Scope: doors.ScopeOnlyDebounce(300 * time.Millisecond, 600 * time.Millisecond),
Indicator: doors.IndicatorOnlyAttrQuery("#search-loader", "aria-busy", "true"),
On: func(ctx context.Context, r doors.RequestInput) bool {
l.options.Update(ctx, l.results(r.Event().Value))
return false
},
})
type="search"
placeholder="Country"
aria-label="Country"
autocomplete="off"/>
~{
l.options.Clear(ctx)
}
~(l.options)
}
func (l countrySelector) results(input string) gox.Elem {
if len(input) == 0 {
return nil
}
if len(input) < 2 {
return <p>
<mark>Type at least two letters to search</mark>
</p>
}
results, _ := driver.Locations.CountriesSearch(input)
if len(results) == 0 {
return <p>
<i>nothing found</i>
</p>
}
scope := doors.ScopeOnlyBlocking()
return <ul>
~(for _, place := range results {
<li
(doors.AClick{
Scope: scope,
PreventDefault: true,
On: func(ctx context.Context, _ doors.RequestPointer) bool {
l.selected.Update(ctx, place)
return true
},
})>
<a href="#">~(place.Name)</a>
</li>
})
</ul>
}
app.gox
package main
import (
"github.com/doors-dev/doors"
"github.com/doors-dev/gox"
)
type App struct{}
elem (a App) Main() {
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hello Doors!</title>
<link
(doors.ResourceExternal("https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css"))
rel="stylesheet">
</head>
<body>
<main class="container">
~(CountrySelector())
</main>
</body>
</html>
}