v0.7.4 beta
Back-end UI Framework

for feature-rich, secure, and fast web apps in Go

Tutorial

Advanced Scope Usage

In practice, the submit handler won’t respond instantly. What if we interact with the UI during processing?

Let’s simulate conflicting actions:

Image description

Concurrent Scope

This issue can be easily mitigated with Concurrent Scope from the Scopes API.

Concurrent Scope can be “occupied” only by events with the same group id.

Add a concurrent scope to the location selector:

type locationSelectorFragment struct {
	location doors.SourceBeam[selectedLocation]
	country  doors.Beam[driver.Place]
	city     doors.Beam[driver.Place]
	apply    func(context.Context, driver.Place)

	// add a concurrent scope
	scope doors.ScopeConcurrent
}

Add a parent scope property to the place selector:

type placeSelector struct {
	label       string
	query       func(string) ([]driver.Place, error)
	update      func(context.Context, driver.Place)
	value       doors.Beam[driver.Place]
	optionsDoor doors.Door

	// new field
	parentScope doors.Scope
}

Assign group Id 1 to both place selectors:

templ (f *locationSelectorFragment) Render() {
	<article>
		@doors.F(&placeSelector{
			/* ... */
			// provide the scope value
			parentScope: f.scope.Scope(1),
		})
		@doors.Sub(f.country, func(country driver.Place) templ.Component {
			// no country selected, no need to render the city selection
			if country.Name == "" {
				return nil
			}
			return doors.F(&placeSelector{
				/* ... */
				// provide the scope value
				parentScope: f.scope.Scope(1),
			})
		})
		@doors.Sub(f.city, func(p driver.Place) templ.Component {
			return f.submit(p)
		})
	</article>
}

Use it on the ‘change place’ button:

templ (f *placeSelector) showSelectedPlace(p driver.Place) {
	<h3>{ f.label }: <b>{ p.Name }</b></h3>
	@doors.AClick{
		Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
		// combine blocking scope with the scope provided by location selector
		Scope: []doors.Scope{&doors.ScopeBlocking{}, f.parentScope},
		On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
			f.update(ctx, driver.Place{})
			return true
		},
	}
	<button class="secondary">Change</button>
}

Finally, apply it to the submit button with a different group Id:

templ (f *locationSelectorFragment) submit(p driver.Place) {
	if p.Name == "" {
		<button disabled>Confirm Location</button>
	} else {
		@doors.AClick{
			Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
			// combine blocking scope with the concurrent scope instance
			Scope: []doors.Scope{&doors.ScopeBlocking{}, f.scope.Scope(0)},
			On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
				f.apply(ctx, p)
				return true
			},
		}
		<button id="submit-location">Confirm Location</button>
		@focus("submit-location")
	}
}

Result

This setup ensures that either the submit or the change-place event can run, not both at the same time:

Image description

While changes to the city and country won’t affect each other, since they share the same group:

Image description

Concurrency control is necessary due to the framework’s non-blocking event model. This is a major advantage of doors over Phoenix LiveView or Blazor Server, enabling highly interactive UIs without UX compromises.

Next: Menu

Code

./location_selector.templ

package main

import (
	"context"
	"github.com/derstruct/doors-dashboard/driver"
	"github.com/doors-dev/doors"
	"time"
)

type selectedLocation struct {
	country driver.Place
	city    driver.Place
}

func locationSelector(apply func(context.Context, driver.Place)) templ.Component {
	location := doors.NewSourceBeam(selectedLocation{})
	// derive the country beam
	country := doors.NewBeam(location, func(p selectedLocation) driver.Place {
		return p.country
	})
	// derive the city beam
	city := doors.NewBeam(location, func(p selectedLocation) driver.Place {
		return p.city
	})
	return doors.F(&locationSelectorFragment{
		location: location,
		country:  country,
		city:     city,
		apply:    apply,
	})
}

type locationSelectorFragment struct {
	location doors.SourceBeam[selectedLocation]
	country  doors.Beam[driver.Place]
	city     doors.Beam[driver.Place]
	apply    func(context.Context, driver.Place)

	// add a concurrent scope
	scope doors.ScopeConcurrent
}

templ (f *locationSelectorFragment) Render() {
	<article>
		// country selector fragment
		@doors.F(&placeSelector{
			label: "Country",
			query: driver.Countries.Search,
			update: func(ctx context.Context, p driver.Place) {
				// update location with the selected country
				// and no city selected
				f.location.Update(ctx, selectedLocation{country: p})
			},
			// provide the country beam
			value: f.country,
			// provide the scope value
			parentScope: f.scope.Scope(1),
		})
		// city depends on the country
		@doors.Sub(f.country, func(country driver.Place) templ.Component {
			// no country selected, no need to render the city selection
			if country.Name == "" {
				return nil
			}
			return doors.F(&placeSelector{
				label: "City",
				query: func(s string) ([]driver.Place, error) {
					// search for cities in the provided country
					return driver.Cities.Search(country.Id, s)
				},
				update: func(ctx context.Context, p driver.Place) {
					// mutate location with the new city
					f.location.Mutate(ctx, func(sl selectedLocation) selectedLocation {
						sl.city = p
						return sl
					})
				},
				// provide the city beam
				value: f.city,
				// provide the scope value
				parentScope: f.scope.Scope(1),
			})
		})
		// submit depends on the city beam
		@doors.Sub(f.city, func(p driver.Place) templ.Component {
			return f.submit(p)
		})
	</article>
}

templ (f *locationSelectorFragment) submit(p driver.Place) {
	if p.Name == "" {
		<button disabled>Confirm Location</button>
	} else {
		@doors.AClick{
			Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
			// combine blocking scope with the concurrent scope instance
			Scope: []doors.Scope{&doors.ScopeBlocking{}, f.scope.Scope(0)},
			On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
				f.apply(ctx, p)
				return true
			},
		}
		<button id="submit-location">Confirm Location</button>
		@focus("submit-location")
	}
}

type placeSelector struct {
	label       string
	query       func(string) ([]driver.Place, error)
	update      func(context.Context, driver.Place)
	value       doors.Beam[driver.Place]
	optionsDoor doors.Door

	// new field
	parentScope doors.Scope
}

templ (f *placeSelector) Render() {
	// some layout
	<section>
		// subscribe to the provided beam
		@doors.Sub(f.value, func(p driver.Place) templ.Component {
			if p.Name == "" {
				return f.selectPlace()
			}
			return f.showSelectedPlace(p)
		})
	</section>
}

templ (f *placeSelector) showSelectedPlace(p driver.Place) {
	<h3>{ f.label }: <b>{ p.Name }</b></h3>
	@doors.AClick{
		Indicator: doors.IndicatorOnlyAttr("aria-busy", "true"),
		// combine blocking scope with the scope provided by location selector
		Scope: []doors.Scope{&doors.ScopeBlocking{}, f.parentScope},
		On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
			f.update(ctx, driver.Place{})
			return true
		},
	}
	<button class="secondary">Change</button>
}

templ (f *placeSelector) selectPlace() {
	// use label and construct unique id for the loader
	<h3>Select { f.label } &emsp;<span id={ "search-loader-" + f.label }></span></h3>
	@f.input()
	{{ f.optionsDoor.Clear(ctx) }}
	@f.optionsDoor
}

templ (f *placeSelector) input() {
	{{ prevValue := "" }}
	@doors.AInput{
		// use unique id to apply indication
		Indicator: doors.IndicatorOnlyAttrQuery("#search-loader-"+f.label, "aria-busy", "true"),
		Scope:     doors.ScopeOnlyDebounce(300*time.Millisecond, 600*time.Millisecond),
		On: func(ctx context.Context, r doors.REvent[doors.InputEvent]) bool {
			term := r.Event().Value
			term = term[:min(len(term), 16)]
			if term == prevValue {
				return false
			}
			prevValue = term
			if len(term) == 0 {
				f.optionsDoor.Clear(ctx)
				return false
			}
			f.optionsDoor.Update(ctx, f.options(term))
			return false
		},
	}
	{{ inputId := "search-input-" + f.label }}
	<input id={ inputId } type="search" placeholder={ f.label } aria-label={ f.label } autocomplete="off"/>
	@focus(inputId)
}

templ focus(id string) {
	@doors.AData{
		Name:  "id",
		Value: id,
	}
	@doors.Script() {
		<script>
            const id = $data("id")
            const el = document.getElementById(id)
            el.focus()
        </script>
	}
}

templ (f *placeSelector) options(term string) {
	if len(term) < 2 {
		<p>
			<mark>Type at least two letters to search</mark>
		</p>
	} else {
		{{ places, _ := f.query(term) }}
		if len(places) == 0 {
			<i>Nothing found</i>
		} else {
			{{ scope := doors.ScopeOnlyBlocking() }}
			for _, place := range places {
				// attach keydown event hook
				@doors.AKeyDown{
					Scope: scope,
					// filter by "Enter" key
					Filter: []string{"Enter"},
					On: func(ctx context.Context, r doors.REvent[doors.KeyboardEvent]) bool {
						f.update(ctx, place)
						return true
					},
				}
				@doors.AClick{
					Scope: scope,
					On: func(ctx context.Context, r doors.REvent[doors.PointerEvent]) bool {
						f.update(ctx, place)
						return true
					},
				}
				// add tabindex attribute
				<p tabindex="0" role="link" class="secondary">
					{ place.Name }
				</p>
			}
		}
	}
}