Skip to content

Reactivity

When you define a form with components & layouts, Iridium uses your definition to create a navigable component tree at run time to handle your form's request lifecycles.

This allows you to write reactive callbacks on your form components that can navigate and mutate different form elements when a user interacts with them.

Users then will see these changes live as they interact with your form. On a reasonably fast connection, it will appear like field validation & reactivity is happening client-side, while it actually happens server-side.

Benefits

  • A server-side based reactive form requires you the developer to only write field level validation and reactivity one time (on the server in Go), rather than twice (once in JS on the client for reactivity and validation, and then once in Go for validation).
    • Since you only write field validation on the server, there can be no mismatch between client/server side validation which helps prevent regressions & bugs.

Drawbacks

  • Server-side reactivity is generally limited by how fast your network connection is. On slow connections, your form can take a bit to update after a user has interacted with it.

When to use Reactivity

  • Validate a field's data as a user interacts with it to immediately notify them if they have made an invalid choice.
  • Display/Hide certain form elements based on the state of others.
  • Mutate the current and other form values based on the state of your form.

AfterStateUpdated

Form fields contain an AfterStateUpdated hook that allows you to write a custom callback that uses the current request to mutate it's and other form's values.

Live

After writing a AfterStateUpdated method, if you want user input on that form element to trigger its callback in a live manner, you'll need to include the Live() method on your field. See more about live fields here.

Mutate the current value

Here we've set up the this FormInput to always upper case whatever text is written into it:

go
// Will run `AfterStateUpdated` if a user changes the `name`
FormInput("name").
    Live().
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        return strings.ToUpper(state), nil
    },
),

State Types

Behind the scenes, Iridium stores all form values as string arrays []string. This is inline with how HTML forms work, and the url.Values variable in Go's net/http package which you generally parse your form data into.

However, working with string arrays directly to set default values, or read field values, would require you to write ugly type casting code everywhere if you intend to do anything useful.

Example:

go
// Age should be an integer here. Lets see an example without Iridium's type casting.//

// - No Type Casting -
// Default type for a FormInput is `string`,
// hence `string` is passed to the callback and accepted as a default value
FormInput("Age").
    DefaultValue("1"). // Requires a string here - which is not ideal for an int.
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
         return strconv.Itoa(strconv.Atoi(state) + 1), nil // Messy...
         // Why is this even necessary? I know it's an integer!
    },

To avoid this, you can indicate to Iridium the value type for a form component through its different configuration methods and allow it to automatically cast your values for you.

For example, cast your FormInput with a call to it's Integer() method, and it now provides and accepts int values instead of string values.

go
// Age should be an integer here. Lets see an example with Iridium's type casting.//

// - With Type Casting -
// Defining the form input as an integer will cause the 
// callback to receive an `int` instead of a `string`
FormInput("Age").
    Integer().
    DefaultValue(1). // Accepts an int here
    AfterStateUpdated(func(state int, ctx FieldContext) (int, error) {
        return state + 1, nil // Nice!
    },
),

You can imagine the usefulness of this, particularly when dealing with a date/time input that already casts an nice array of dates for you to work with.

Notes:

  • Of course, inside of Iridium, that int is still a []string, but working with State Types gives you a much nicer developer experience.
  • Also, while the first example is ugly since it requires you to write type casting code, it still works fine.

Typing 'gotchas'

There are a few things to keep in mind when working with type casting.

1. Order Matters

We recommend you write type-dependent methods after calling type mutating methods.

For example, a FormInput for an integer should have it's Integer() method called before its AfterStateUpdated or DefaultValue method. This allows Iridium to correctly recast your form component with an appropriate type.

go
// ❌Not Recommended 
// Your `Integer` method was called after your `AfterStateUpdated` method,
// which prevents Iridium from correctly casting the value to an `int`
// This still works, but now you have to cast it yourself.
FormInput("Age").
    DefaultValue("1"). // Requires a string here - which is not ideal.
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        return strconv.Itoa(strconv.Atoi(state) + 1), nil // Messy...
    }).
    Integer() // Should be before AfterStateUpdated & DefaultValue
),

// Correct ✅
FormInput("Age").
    Integer().
    DefaultValue(1). // Accepts an int here
    AfterStateUpdated(func(state int, ctx FieldContext) (int, error) {
        return state + 1, nil // Nice!
    })
),

2. Empty/Default Single Values

Iridium can struggle to understand when a user input should be considered null or the type's default value.

For example, for a FormInput marked as Integer, does a blank string represent 0 or null? Furthermore, should it run the AfterStateUpdated callback with a blank string which is type corrected to 0? If you return the state, then a blank string magically becomes 0 on your form!

To overcome this ambiguity, you can pass a boolean directly after your AfterStateUpdated method to tell Iridium whether you want your method run on blank entries.

go
// If a user has a "" value for the age field, this callback will be skipped!
FormInput("Age").
    Integer().
    AfterStateUpdated(func(state int, ctx FieldContext) (int, error) {
        return state + 1,
    }, true)
    
// If a user has a "" value for the age field, the callback will still run.
// You'll be provided the integer's default value of `0`
FormInput("Age").
    Integer().
    AfterStateUpdated(func(state int, ctx FieldContext) (int, error) {
        return state + 1,
    }, false)

Important Cavet For Default Behaviour

By default, Iridium will skip AfterStateUpdated when the form's field is blank to guard against ambiguity.

However, this only applies to types which are not string or any array type. Those are not ambiguous, as they are simply passed blank strings or arrays with lengths of 0. Hence, these types' AfterStateUpdated methods will always be called on blank entries by default.

You can still override any default behaviour by passing true or false directly after your AfterStateUpdated method however.

Set

The Set method allows you to set the value of another form field. Set is a generated method that is received from type generation.

Set accepts 4 pieces of information:

  • The path to the field you want to set.
  • The value you want to set it to.
  • The current field context.
  • A generic type. (Optional via inference)

Basic Set Example

Here is a basic example of setting the value of a FormInput based on the value of another field.

go
// Here, when a user enters their name into the "Name" field,
// it will automatically grab their last name and set the "LastName" field

FormInput("Name").
    Live(). // <- Live indicates this field should trigger a request to the server when interacted with.
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        names := strings.Split(state, " ")
        if len(names) < 2 {
            return state, nil
        }
    
        Set[string](ctx, "LastName", names[1]) 
        return state, nil
    })
FormInput("LastName").
  Readonly()
## Get

Set Types

Set accepts a generic type parameter, to allow you to set the value of a field to a specific type. This type is inferred by Go based on the type of the value you pass to it, so it can be omitted to save you some typing.

go
// Explicit
Set[string](ctx, "LastName", "Smith") // Accepts a string
Set[int](ctx, "Age", 25) // Accepts an int
Set[[]time.Time](ctx, "DaysOff", []time.Time{time.Now(), time.Now().Add(24 * time.Hour)}) // Accepts []time.Time

// Example of inferred type
Set(ctx, "Statues", []bool{true,false,true})

Target Types

A Set type should match the target field's type.

Int Target Example:

go
FormInput("Age").
    Integer()
    
FormSwitch("CanDrink").
    Live().
    AfterStateUpdated(func(state bool, ctx FieldContext) (bool, error) {
        if state {
            Set[int](ctx, "Age", 18)
        }
        return state, nil 
    })

Array Target Example:

go
FormSelect("Statues").
    Multiple(). // Multiple changes `Statues` type from `string` to `[]string`
    OptionsUnordered(map[string]string{"joe": "Joe", "miranda": "Miranda", "larry": "Larry"})
    
FormSwitch("AutoSelectManagers").
    Live().
    AfterStateUpdated(func(state bool, ctx FieldContext) (bool, error) {
        if state {
            Set[[]string](ctx, "Statues", []string{"joe", "miranda"})
        }
        return state, nil 
    })

Type Targets at Compile Time

Without more advanced code generation (that may come in the future), Iridium can not enforce correct type targets at compile time. Please keep this in mind when mutating the underlying type of a targeted field. If a FormInput is marked as Integer, or Float, you should update Set calls targeting it to correctly match it's type, and so on for other fields.

If a type mismatch occurs, Iridium will error.

Get

The Get method allows you to retrieve the value of another form field. Get is a generated method that is received from type generation.

Get accepts 3 pieces of information:

  • The path to the field you want to retrieve.
  • The current field context.
  • A generic type.

Basic Get Example

Here is a basic example of retrieving the value of a FormInput based on the value of another field.

go
honorifics := map[string]string{
  "mr": "Mr", "mrs": "Mrs", "trh" : "The Right Honourable",
}


FormSelect("Honorifics").
    OptionsUnordered(honorifics),


// We'll automatically prepend the user's chosen honorific to their name!
FormInput("Name").
    Live().
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        val, err := Get[string](ctx, "Honorifics")
        if err != nil { return state, err }
        
        honor, ok := honorifics[val]
        if !ok { return state, nil }
        
        return honor + " " + state, nil
    })

Tip: It'd be a good idea here to also use Get inside of DisabledFn callback to force a user to select an honorific before entering their name.

Get Raw

Get not only returns the value of the targeted field, but it also runs any validation/rules attached to it. If a targeted field fails validation, an error will be returned.

To retrieve the raw value of a field without running any validation, use GetRaw. It has an identical signature to Get. Just make sure you're careful to validate the raw value if you intend to use it for anything sensitive, and you don't trust your users.

go
GetRaw[string](ctx, "Name")
Why Validate?

Imagine Get did not validate the value before returning it.

See if you can spot the security vulnerability in the following example. Keep in mind that OptionsUnordered applies an implicit validation rule so that the only accepted values are the keys of the map. The server will verify that for you.

go
FormSelect("folder").
    OptionsUnordered(map[string]string{"folderA": "Folder A", "folderB": "Folder B"}),
    
FormFileUpload("files").
  StoragePathFn(func(ctx FieldContext) string { 
    path, err := ctx.Get[string](ctx, "folder")
    if err != nil {
      return "default"
    }
    return path
  })

Answer:

Just because the client and server verify that "folder A" and "folder B" are the only valid options doesn't mean a user couldn't submit their own custom form values with, for example, "folderC" as the value of "folder". That would fail validation for the "folder" field and warn the user, but your Get call would still return "folderC" and end up uploading a file to a folder you didn't intend. You would (reasonably) assume when developping that if you said only 2 options are avaliable, you'll only ever get one of the two when using Get. That is why Get validates the value before returning it.

TL;DR: If you trust your users, feel free to use GetRaw to retrieve the raw value of a field if you need it. Otherwise, you should always use Get.

Set/Get in Callbacks

Set & Get are not limited to your AfterStateUpdated hook. They can be used in any field's configuration callbacks to set or retrieve values from other fields.

You can set the label of a field based off another, hide a section based on another, or a multitude of other things.

All field callbacks are re-evaluated on each render. See more about this in the live section.

Example

Here we're going to simply change the label of a text area to match a chosen job.

go
jobs := map[string]string{
  "full_time": "Full Time", "part_time": "Part Time",
}

FormSelect("Job").
    Live().
    OptionsUnordered(jobs),

// Label will be "Description for Full Time jobs"
// or "Description for Part Time jobs" depending on the user's selection.
// No selection means a label of "Description"
FormTextArea("Description").
    LabelFn(func(ctx FieldContext) string {
        if val, err := Get[string](ctx, "Job"); err != nil {
            return "Description"
        }
        
        text, ok := jobs[val]
        if !ok { return "Description" }
        
        return fmt.Sprintf("Description for %s jobs", text)
    })

Pathing

Navigation is done by providing a path in the FieldContext's Get/GetArr & Set/Set methods.

Inward

In this example, we'll navigate into a FormGrid to set a value:

go
// If a user updates "name", it will grab their last_name and set the "last_name" field
// inside the "details" grid. Note the pathing
FormInput("name").
    Live().
    AfterStateHydrated(func (value string, ctx FieldContext) (string, error){
        names := strings.Split(value, " ")
        if len(names) < 2 {
            return value, nil // Do nothing if no space between names
        }
        Set(ctx, "./details/last_name", names[1])
        return value, nil
    }),
    
FormGrid("details").
    Schema(
        // Automatically set whenever a user types in the "name" field
        TextInput("last_name")
    )

Outward

In this example, we'll navigate out of a grid, to set a field on the main form

go
// We'll have a user enter their age, 
// then toggle a `CanDrink` switch for them
FormGrid("details").
    Schema(
        TextInput("age").
          Live().
          Integer(). // Mark this as a integer
          AfterStateHydrated(func (value int, ctx FieldContext) (int, error){
              over17 := value > 17
              Set(ctx, "../CanDrink", over17)
	          return value, nil
          }),
    ),
FormSwitch("CanDrink")

Relative

Pathing is relative, so if two elements are within the same layout, they can directly reference each other:

go
// We'll have a user enter their age, 
// then toggle a `CanDrink` switch for them
FormGrid("details").
    Schema(
        TextInput("age").
          Live().
          Integer(). // Mark this as a number input
          AfterStateHydrated(func (value int, ctx FieldContext) (int, error){
              over17 := value > 17
              
              // Note! -> Can directly reference the `CanDrink` field here.
              // A good heuristic is layout schemas are considered like directories!
              Set(ctx, "CanDrink", over17)  
	          return value, nil
          }),
          FormSwitch("CanDrink")
    )

Chains

When using Set, inside one form element to set the value of another, it will also run any AfterStateUpdated methods attached to that form element. This chain will continue for as many fields exist with AfterStateUpdated methods that set each other's values

Example

In this example, if a user updates the name field, it will then set the city field to "Toronto". That city field's AfterStateUpdated method will then run, with the supplied value being "Toronto".

go
FormInput("name").
    Live().
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        Set(ctx, "city", "Toronto")
        return state, nil
    },
),
FormInput("city").
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        // This `AfterStateUpdated` method was fired when you changed the 
        // value of this field in `AfterStateUpdated` method inside 
        // of the name field
        fmt.Print(state) // state is now "Toronto". 
        return state, nil
    },
),

Infinite Loops & Depth Limits

You may question what happens if two fields continually update each other, or some cycle appears in your chain. By default, Iridium tracks visited nodes/fields during a chain, and will have your Set call return an error if it detects a cycle.

Additionally, Iridium will limit the depth of the chain to 20 nodes as a safety measure. If you're running into this limit, it's a sign you may have overcomplicated your form.

Chain Validation

When interacting with a form component via a live request, it will run the validation for the current field and any other fields visited during its chain.

These validation errors will be displayed to the user, and will re-run on all future requests until they are cleared.

Iridium does not validate the entire form during live requests to avoid blasting the users with errors, if they start editing only one field.

Live

The Live method applies attributes to your form field that will trigger a request to the server when interacted with.

For certain form elements the request is triggered with no delay (say for switchs, radios, single date/time pickers, etc.), and for others like the text input field, the live request is triggered after a debounce.

Any field marked Live will run it's AfterStateUpdated hook when triggered.

Live Attributes

The attributes applied during a live request are similar the following:

html
<input hx-post="/form/live" hx-trigger="changed input delay:500ms"
    hx-target="form" hx-swap="outerHTML"/>

<!-- For inputs like a switch, there is no included delay -->

Custom Live Triggers

Using Iridium's attributes you can customize the triggers for your live requests by passing a different "hx-trigger" value to overwrite the default.

For example, some users may type particularly slowly into a FormInput field and cause unwanted requests to the server. You could therefore, change the live trigger from a debounce to "keyup[enter]", or a lose-focus event.

go
FormInput("name").
    Live().
    Attributes(map[string]string{"hx-trigger": "keyup[enter]"}).

Warning

Certain fields handle network requests differently due to their complexity. For example, a FormSelect manages its own network requests inside an Alpine.js component. For V1 of Iridium, these are not overridable using attributes and require component overriding.

Live for validation

If a field does not include a AfterStateUpdated hook, Live can still be applied to trigger that field's validation lifecycle after it receives some input to provide a warning to the user if they have entered incorrect information.

go
// Entering a name that doesn't end in "Smith" will trigger a validation error
// on the "Name" field as the user types.
FormTime("Name").
    Live().
    EndsWith("Smith")

Live for form re-renders

Live is also useful for re-evalutating your form. When you customize form components with dynamic callbacks, like LabelFn, those are re-evaluated on each render.

This really allows your form elements to be dynamic. A particularly useful example is triggering the hidden state of a FormGrid based on the value of another field.

go
// Will display a different form grid based on the selected option.
// The `Live` call here will re-evaluate 
// the `VisibleFn` function on each render.
FormTime("JobType").
    Live().
    OptionsUnordered(map[string]string{
      "full_time": "Full Time", "part_time": "Part Time",
    })

// Only show if JobType is "full_time"
FormGrid("FullTimeInfo").
    VisibleFn(func(ctx FieldContext) bool {
        val, err := Get[string](ctx, "JobType")
        if err != nil { return false }
        return val == "full_time"
    }).
    Schema(
        /* Full Time Info Grid Schema */
    )

// Only show if JobType is "part_time"
FormGrid("PartTimeInfo").
    VisibleFn(func(ctx FieldContext) bool {
        val, err := Get[string](ctx, "JobType")
        if err != nil { return false }
        return val == "part_time"
    }).
    Schema(
        /* Part Time Info Grid Schema */
    )

FieldContext

The field context provides useful methods for mutating your form within the AfterStateUpdated method.

Set

The FieldContext's Set method can be used to set the values of other fields. This is a wrapper for SetArr.

A basic example is as follows. When the user updates the field "name", it will set another form field called "First Name" with the first word in the name.

go
// Will run `AfterStateUpdated` if a user changes the `name`
FormInput("name").
    Live().
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        // get the first & last names
        names := strings.Split(state, " ")
        // set the "first_name" field to the first name
        ctx.Set("first_name", names[0])
        return state, nil
    },
),

// The value of `first_name` is updated via 
// the `ctx.Set` function inside name's AfterStateUpdated callback
FormInput("first_name").
    Label("First Name"),

SetArr

The FieldContext's SetArr method is used to all of a field's values:

go
// Will automatically select all the models for a particular
// car make, when the make is typed in
FormInput("make").
    Live().
    AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
        // Example method
        var ids []string
        ids = GetModelIdsByCarMake(state)
        
        ctx.SetArr("first_name", ids)
        return state, nil
    },
),

// This field contains all the models
FormSelect("model").
  Multiple()

Set or SetArr?

All values for the form, include fields that support one or many values, are stored as []string apart of a url.Values variable behind the scenes.

SetArr allows you to directly set that []string value for each form element, which is necessary if the form element you are mutating has multiple values. Set is just a convenience wrapper when your targeted field only support one value (like a switch).

Get

The FieldContext's Get method is used to fetch a value from another form field. Get is a wrapper for GetArr.

go
FormSwitch("UpperCase").
  Labe("Upper case all content?"),
FormTextArea("Content").
  Live().
  AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
      if ctx.Get("UpperCase") == "ok" {
        return strings.ToUpper(state), nil
      }
      return state, nil
  })

This is example is a bit contrived. You'd also want FormSwitch("Uppercase") to uppercase everything when toggled as well

GetArr

GetArr will return all the values of your targeted field:

go
// This form field could have many values
FormSelect("Jobs").
  Multiple()

// We'll always make the first line of content be a list of the jobs selected.
FormTextArea("Content").
  Live().
  AfterStateUpdated(func(state string, ctx FieldContext) (string, error) {
      jobs := ctx.GetArr("Jobs")

      if len(jobs) > 0 {
          state = "Write up on the following jobs: " + strings.Join(jobs, ", ") + "\n" + state
      }
      return state, nil
  })

Get or GetArr?

All values for the form, include fields that support one or many values, are stored as []string apart of a url.Values variable behind the scenes.

GetArr allows you to directly get that []string value for each form element, which is necessary if the form element you are targeting has multiple values. Get is just a convenience wrapper when your targeted field only support one value (like a switch).

Data

Model

Client Side Reactivity

Iridium's core focus is on server-side validation & reactivity, however client-side validation & reactivity still has it's place.

Iridium natively supports some basic HTML input rules and will support better Alpine.js hook integration in the future.

If you need access to client-side logic today, you'll need to read through our attributes section.

Released under the MIT License.