--- title: The trickiness of HTML checkbox date: 2025-10-22 16:29:02.692510 UTC --- I'm using [Lustre](https://hexdocs.pm/lustre/) framework to rebuild the Admin area of [this blog](https://github.com/hongquan/QuanWeb). When implementing the form for editing blog post, I'm surprised how tricky to handle the checkbox, which may look simple at first. In CRUD applications, people often use the checkbox to represent a boolean field. The "checked" status is for `True` and unchecked is for `False`. Take this form as example, when I want to publish a post, I tick the "Published" checkbox and save. If I want to unpublish, I untick and save. ![Checkbox as boolean field](https://cdn.imgchest.com/files/1e1d6a0a6f82.png) Most of people don't see any issue with this usage. When I make frontend apps with VueJS, with an edit form like this, I often bind each `` element with a reactive variable, via `v-model`: ```vue
``` Vue, and maybe other frameworks of the same kind (React, Svelte etc.), supports this use case extremely well. When I make frontend apps with [Lustre](https://hexdocs.pm/lustre/), a framework with quite different architecture from the above names, I choose the approach which looks more like in backend side. I just collect the data that a HTML `
` submitted, passing it to a form validation object (I use [`formal`](https://hexdocs.pm/formal/) for this), then taking the ouput of this validation to call API. [MUV](https://guide.elm-lang.org/architecture/) (Model-Update-View) frameworks like Lustre has a central `Msg` type and a central `update()` function to process those messages, so I don't want to inflate this `Msg`. If we bind each `` like in VueJS, we will create a lot of variants for `Msg` and the central `update()` will be very long! With the "process form as a whole" approach, I only need one variant of `Msg`: ![Message for form submitted](https://cdn.imgchest.com/files/86846a0725f4.png) And that is when I realized the quirk of checkbox: If I uncheck the `is_published` checkbox, the data submitted by `` will miss the `is_published` field. The data that we then send to API to "patch" the `Post` object also miss this field and doesn't make change to the `Post` object. At the end, the `Post` is still published even though we as user already unticked the checkbox. The "only include if being checked" behavior is actually what HTML checkbox is designed from the beginning. We are just bending it to our custom use. My resolution is to add one more field to the app `Model` to track the boolean status of the checkbox. It is similar to what we do in a Vue app, but we only do for checkboxes, not all ``. We define a `Checkboxes` type to track all checkboxes we will have in the app and give it a seat in `Model`: ```gleam pub type CheckBoxes { CheckBoxes(is_published: Bool) } pub type Model { Model( route: Route, ... post_form: Option(Form(PostEditablePart)), checkboxes: CheckBoxes, ) } ``` We define a message that will be emitted when user tick / untick the checkbox: ```gleam pub type Msg { ... UserToggledIsPublishedCheckbox(Bool) } ``` The message name is weirdly long. I haven't thought of a better message collection to deal with multiple checkbox yet. When we render the checkbox, we base on the `checkboxes` tracking data to make the widget checked or unchecked: ```gleam fn render_is_published_field(is_published: Bool) { h.div([a.class(class_row)], [ h.label([a.class(class_label)], [h.text("Published")]), h.div([a.class("pt-2")], [ h.input([ a.name("is_published"), a.type_("checkbox"), a.checked(is_published), ev.on_check(UserToggledIsPublishedCheckbox), ]), ]), ]) } ``` When handling this message, we just need to update the tracking data in `model`: ```gleam fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) { case msg { UserToggledIsPublishedCheckbox(checked) -> { let model = Model(..model, checkboxes: CheckBoxes(is_published: checked)) #(model, effect.none()) } } } ``` At the step of validating submitted data, with `formal` package, normally we use [`form.add_values`](https://hexdocs.pm/formal/formal/form.html#add_values) to feed the new data to the `Form` object to process. But we should note that when the checkbox is unchecked, the new value for that field is missing, the `Form` will use old data (which was previously added) instead and may produce unwanted result. Unfortunately, `formal` doesn't have a function to remove old data that it is keeping. We have to use [`form.set_values`](https://hexdocs.pm/formal/formal/form.html#set_values) to overwrite all fields. I have done presenting tips to deal with HTML checkboxes. Hope it helps! ## Update **3 Nov 2025** After the release of Lustre [v5.4.0](https://github.com/lustre-labs/lustre/blob/main/CHANGELOG.md#v540---2025-11-01), with the addition of [`attribute.default_checked()`](https://hexdocs.pm/lustre/5.4.0/lustre/attribute.html#default_checked), I no longer need to create the `Checkboxes` type and `UserToggledIsPublishedCheckbox` message. No need to track the checkbox status, just parse the value kept by `formal` to determine the value for `default_checked`. The function to render the `is_published` field is now just as simple as: ```gleam import formal/form.{type Form} as formlib fn render_is_published_field(form: Form(PostEditablePart)) { let is_published = case formlib.field_value(form, "is_published") { "" -> False _ -> True } h.div([a.class(class_row)], [ h.label([a.class(class_label)], [h.text("Published")]), h.div([a.class("pt-2")], [ h.input([ a.name("is_published"), a.type_("checkbox"), a.default_checked(is_published), ]), ]), ]) } ```