Appearance
Create Tasks
The tasks list shows data but offers no way to add anything. In this chapter we'll add the first interactive page — a form for creating new tasks — along with the routing and API call it needs.
Eight files change, one is new[1]:
sh
doable/
└── client/
└── src/
├── api.gleam # post added
├── browser.gleam # history_back added
├── browser_ffi.js # history_back added
├── route.gleam # NewTask route
├── router.gleam # NewTaskPage wired in
├── service/
│ └── task_service.gleam # post_task added
└── page/
├── tasks.gleam # New Task link added
└── new_task.gleam # new task form pageExtending the Routes
route.gleam gains one new variant:
gleam
// client/src/route.gleam
pub type Route {
Tasks
NewTask
}
pub fn to_path(route: Route) -> String {
case route {
Tasks -> "/tasks"
NewTask -> "/tasks/new"
}
}
pub fn from_uri(uri: Uri) -> Route {
case uri.path_segments(uri.path) {
["tasks"] -> Tasks
["tasks", "new"] -> NewTask
_ -> home_route
}
}The Post Method
api.gleam gets a post function following the same shape as get:
gleam
// client/src/api.gleam
pub fn post(
path: String,
decoder: Decoder(a),
json body: String,
) -> Promise(Result(a, ApiError)) {
use req <- with_json_request(path)
req
|> request.set_method(Post)
|> request.set_header("content-type", "application/json")
|> request.set_body(body)
|> execute(expect: 201, decoder:)
}The json: label on body makes call sites read like api.post(path, decoder, json: body) — intent is clear without any extra ceremony.
Extending the Task Service
task_service.gleam gains post_task, which encapsulates the serialization and API call so pages never have to touch JSON directly:
gleam
// client/src/service/task_service.gleam
pub fn post_task(input: TaskInput) -> Promise(Result(Task, ApiError)) {
let body =
input
|> task.task_input_to_json
|> json.to_string
"/api/tasks"
|> api.post(task.task_decoder(), json: body)
} post_task takes a TaskInput, serializes it to JSON internally, and delegates to api.post. The encoding lives here, so new_task.gleam can pass a TaskInput value without knowing anything about JSON serialization.
Browser FFI
After creating a task, the page navigates away. Going back requires calling window.history.back() — something the Gleam standard library doesn't expose. The existing browser module gets one more FFI pair:
gleam
// client/src/browser.gleam
@external(javascript, "./browser_ffi.js", "window_location_origin")
pub fn window_location_origin() -> String
@external(javascript, "./browser_ffi.js", "history_back")
pub fn history_back() -> Niljs
// client/src/browser_ffi.js
export function window_location_origin() {
return window.location.origin;
}
export function history_back() {
window.history.back();
}history_back returns Nil — Gleam's equivalent of a void function — since window.history.back() is called for its side effect, not a value.
The New Task Page
page/new_task.gleam is a self-contained Lustre page. The model is a flat record — there is nothing to fetch before the form can be shown:
gleam
// client/src/page/new_task.gleam
pub type Model {
Model(name: String, description: String, submitting: Bool, error: Option(String))
}
pub type Msg {
UserUpdatedName(String)
UserUpdatedDescription(String)
UserClickedBack
UserSubmittedForm
ApiCreatedTask(Result(Task, ApiError))
}
pub fn init() -> #(Model, Effect(Msg)) {
#(Model(name: "", description: "", submitting: False, error: None), effect.none())
}Five messages cover everything the page needs to handle:
UserUpdatedName/UserUpdatedDescription— field changes as the user typesUserClickedBack— back button clickedUserSubmittedForm— save button clickedApiCreatedTask— result of the POST request
update applies field changes and handles the two user actions — back and submit:
gleam
// client/src/page/new_task.gleam
pub fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
UserUpdatedName(name) -> #(Model(..model, name:), effect.none())
UserUpdatedDescription(description) -> #(
Model(..model, description:),
effect.none(),
)
UserClickedBack -> #(model, effect.from(fn(_) { browser.history_back() }))
UserSubmittedForm ->
case model.name {
"" -> #(Model(..model, error: Some("Name is required")), effect.none())
_ -> #(
Model(..model, submitting: True, error: None),
post_task(model.name, model.description),
)
}
ApiCreatedTask(Ok(_)) -> #(
model,
modem.push(route.to_path(route.Tasks), None, None),
)
ApiCreatedTask(Error(err)) -> #(
Model(..model, submitting: False, error: Some(error.message(err))),
effect.none(),
)
}
}UserSubmittedForm validates that the name isn't empty before setting submitting: True and firing the request. On success, modem.push navigates to /tasks and adds an entry to the browser history so the back button works. UserClickedBack wraps browser.history_back() in effect.from — Lustre requires all side effects to go through the effect system, even synchronous ones.
view renders the form inline:
gleam
// client/src/page/new_task.gleam
pub fn view(model: Model) -> Element(Msg) {
html.div([], [
html.h1([], [element.text("New Task")]),
case model.error {
None -> element.none()
Some(err) -> html.p([], [element.text(err)])
},
html.div([], [
html.label([], [element.text("Name")]),
html.input([
attribute.type_("text"),
attribute.placeholder("Task name"),
attribute.value(model.name),
event.on_input(UserUpdatedName),
]),
]),
html.div([], [
html.label([], [element.text("Description")]),
html.textarea(
[
attribute.placeholder("Optional description"),
event.on_input(UserUpdatedDescription),
],
model.description,
),
]),
html.div([], [
html.button(
[attribute.disabled(model.submitting), event.on_click(UserSubmittedForm)],
[
element.text(case model.submitting {
True -> "Saving..."
False -> "Save"
}),
],
),
html.button([event.on_click(UserClickedBack)], [element.text("Back")]),
]),
])
}The submit button is disabled while submitting is True and its label switches to "Saving..." for immediate feedback. The form lives directly in this module for now — the next chapter extracts it into a shared component when the edit page needs the same fields.

The private post_task function builds the API call as an effect:
gleam
// client/src/page/new_task.gleam
fn post_task(name: String, description: String) -> Effect(Msg) {
use dispatch <- effect.from
TaskInput(name:, description:, completed: False)
|> task_service.post_task
|> promise.map(ApiCreatedTask)
|> promise.tap(dispatch)
Nil
}use dispatch <- effect.from is the standard Lustre pattern for bridging async work into the message system: effect.from provides dispatch, promise.tap calls it with the result, and the trailing Nil satisfies the fn() -> Nil return type.
Wiring the Router
router.gleam gains a new page variant and message type:
gleam
// client/src/router.gleam
pub type Page {
TasksPage(tasks.Model)
NewTaskPage(new_task.Model)
}
pub type Msg {
OnRouteChanged(route.Route)
TasksPageSentMsg(tasks.Msg)
NewTaskPageSentMsg(new_task.Msg)
}update and view each gain a branch following the same pattern as TasksPage. A catch-all is also added:
gleam
// client/src/router.gleam
pub fn update(page: Page, msg: Msg) -> #(Page, Effect(Msg)) {
case msg, page {
OnRouteChanged(route), _ -> page_from_route(route)
TasksPageSentMsg(page_msg), TasksPage(page_model) -> {
let #(new_page_model, effect) = tasks.update(page_model, page_msg)
#(TasksPage(new_page_model), effect.map(effect, TasksPageSentMsg))
}
NewTaskPageSentMsg(page_msg), NewTaskPage(page_model) -> {
let #(new_page_model, effect) = new_task.update(page_model, page_msg)
#(NewTaskPage(new_page_model), effect.map(effect, NewTaskPageSentMsg))
}
_, _ -> panic as "mismatched msg and page"
}
}The _, _ -> panic catch-all makes routing bugs immediately visible. A message arriving for the wrong page is a programming error — panicking during development is preferable to silently swallowing it.
page_from_route maps the new route to its page:
gleam
// client/src/router.gleam
fn page_from_route(route: route.Route) -> #(Page, Effect(Msg)) {
case route {
route.Tasks -> {
let #(page_model, effect) = tasks.init()
#(TasksPage(page_model), effect.map(effect, TasksPageSentMsg))
}
route.NewTask -> {
let #(page_model, effect) = new_task.init()
#(NewTaskPage(page_model), effect.map(effect, NewTaskPageSentMsg))
}
}
}New Task Link
tasks.gleam adds a link above the task list:
gleam
// client/src/page/tasks.gleam
pub fn view(model: Model) -> Element(Msg) {
html.div([], [
html.h1([], [element.text("Tasks")]),
html.a([attribute.href(route.to_path(route.NewTask))], [
element.text("New Task"),
]),
case model.tasks {
Error(err) -> html.p([], [element.text(error.message(err))])
Ok([]) if model.loading -> html.p([], [element.text("Loading...")])
Ok([]) -> html.p([], [element.text("No tasks yet")])
Ok(tasks) -> html.ul([], list.map(tasks, view_task))
},
])
}The link uses route.to_path rather than a hardcoded string, so if the route's path ever changes the update happens in one place.
What's Next
Users can create tasks, but not edit or delete them yet — and the form fields would have to be duplicated if we added an edit page naïvely. Next, we'll extract them into a shared component, then build editing and deletion on top.