Saving forms
Before you start
This guide extends the profile form from
Typed forms and validation,
whose submit handler saves before it returns. That is enough when saving is
fast. When saving is slow, the visitor can keep editing, or the page needs
"unsaved changes" and reset to the last saved values, wrap the form in a
FormWorkflowclass FormWorkflow[Owner, Schema, Domain, Failure]Pure baseline, revision, reset, and one-in-flight-save state.. It also uses
asynchronous work.
Wrap the form in a workflow
The model stores Definition.Workflow[SaveError] instead of the form. It holds
the current form, the last saved values (the baseline), and the state of at most
one save. Submit starts the save:
case Msg.Save(event) =>
model.workflow.updated(event.form).beginSave match
case FormSaveStart.Invalid(next) =>
ZIO.succeed(model.copy(workflow = next))
case FormSaveStart.AlreadySaving(current, _) =>
ZIO.succeed(model.copy(workflow = current))
case FormSaveStart.Started(next, submission) =>
val token = submission.token
ctx.async
.start(SaveKey)(profiles.save(submission.value))(Msg.Saved(token, _))
.as(model.copy(workflow = next))View source (documentation/fixtures/guides/src/forms/ProfileSaveLiveView.scala:22-32)
beginSave returns the next workflow together with a submission that captures
the valid value. The handler starts persistence with ctx.async.start and
returns the Saving workflow in the same turn. The completion arrives as a
later message, so it always finds the matching Saving state, even when the
save finishes immediately. The invalid branch renders next.current with all
errors visible, and AlreadySaving rejects a second submit while one is in
flight.
The view reads the current form from workflow.map(_.current). isDirty
compares the current values with the baseline, and isSaving reports whether a
save is in flight; the complete recipe below uses both to label the button and
show "You have unsaved changes."
Apply the save result
Each save captures a valid value, its editable values, a token, and a revision.
The completion message carries the captured FormSubmissionToken unchanged.
Apply exactly one completion transition for it:
case Msg.Saved(token, result) =>
val transition = result match
case LiveAsyncResult.Succeeded(_) => model.workflow.saveSucceeded(token)
case LiveAsyncResult.Failed(_) =>
model.workflow.saveFailed(token, SaveError("The profile could not be saved."))
case LiveAsyncResult.Cancelled(_) => model.workflow.saveCancelled(token)
transition match
case FormWorkflowTransition.Applied(next) => ZIO.succeed(model.copy(workflow = next))
case FormWorkflowTransition.Stale(_) => ZIO.succeed(model)View source (documentation/fixtures/guides/src/forms/ProfileSaveLiveView.scala:36-44)
A completion carrying a token from another save attempt is Stale and leaves
the model unchanged.
A successful save advances the baseline and replaces the current form only
when no newer revision exists; edits made while saving stay current and remain
dirty. Use saveFailed to retain the failed submission for retry diagnostics
and saveCancelled to return to idle without advancing the baseline.
Show a failure for the submitted draft
Use failureForCurrentRevision when failure feedback should apply only to the
draft that was submitted. The view renders it with
workflow.map(_.failureForCurrentRevision).option(...), and an explicit dismiss
action preserves the draft and its interaction state:
case Msg.DismissFailure =>
ZIO.succeed(model.copy(workflow = model.workflow.dismissFailure))View source (documentation/fixtures/guides/src/forms/ProfileSaveLiveView.scala:48-49)
The query returns a failure only when the failed submission's revision equals
the current revision. Blur, feedback-only updates, and identical-value updates
retain relevance. Changing editable values hides the feedback but keeps the
failed submission in save for diagnostics. Editing away and back does not
revive the failure, because the revision has advanced.
A matching failure completion can be Applied after newer edits while
failureForCurrentRevision returns None: accepting a completion token is not
the same as deciding its feedback applies to the current draft. Applications
can still inspect save for failures whose display policy is not draft-specific.
dismissFailure clears the stored Failed state to Idle, preserving the
current form, baseline, revision, and submission-token correlation. It leaves
idle and saving workflows unchanged; it does not cancel persistence or reset
the form. Failure storage is the last retained failed attempt, not an audit log.
Reset to the baseline
Call workflow.reset to return to the baseline, and handle
FormWorkflowReset.AlreadySaving explicitly:
case Msg.Reset =>
model.workflow.reset match
case FormWorkflowReset.Reset(next) => ZIO.succeed(model.copy(workflow = next))
case FormWorkflowReset.AlreadySaving(_, _) => ZIO.succeed(model)View source (documentation/fixtures/guides/src/forms/ProfileSaveLiveView.scala:53-56)
reset returns FormWorkflowReset.AlreadySaving(current, submission) instead of
discarding an in-flight save, so cancellation policy remains an application
decision. Do not replace an active workflow with a fresh one: that discards its
in-flight correlation state and restarts submission token generation.
Study the complete recipe
package forms
import zio.*
import scalive.*
final case class SaveError(message: String)
final class ProfileSaveLiveView(profiles: Profiles)
extends LiveView[ProfileSaveLiveView.Msg, ProfileSaveLiveView.Model]:
import ProfileSaveLiveView.*
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model(Profile.Definition.workflow(Profile.Definition.initial())))
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Validate(event) =>
ZIO.succeed(model.copy(workflow = model.workflow.updated(event.form)))
case Msg.Save(event) =>
model.workflow.updated(event.form).beginSave match
case FormSaveStart.Invalid(next) =>
ZIO.succeed(model.copy(workflow = next))
case FormSaveStart.AlreadySaving(current, _) =>
ZIO.succeed(model.copy(workflow = current))
case FormSaveStart.Started(next, submission) =>
val token = submission.token
ctx.async
.start(SaveKey)(profiles.save(submission.value))(Msg.Saved(token, _))
.as(model.copy(workflow = next))
case Msg.Saved(token, result) =>
val transition = result match
case LiveAsyncResult.Succeeded(_) => model.workflow.saveSucceeded(token)
case LiveAsyncResult.Failed(_) =>
model.workflow.saveFailed(token, SaveError("The profile could not be saved."))
case LiveAsyncResult.Cancelled(_) => model.workflow.saveCancelled(token)
transition match
case FormWorkflowTransition.Applied(next) => ZIO.succeed(model.copy(workflow = next))
case FormWorkflowTransition.Stale(_) => ZIO.succeed(model)
case Msg.DismissFailure =>
ZIO.succeed(model.copy(workflow = model.workflow.dismissFailure))
case Msg.Reset =>
model.workflow.reset match
case FormWorkflowReset.Reset(next) => ZIO.succeed(model.copy(workflow = next))
case FormWorkflowReset.AlreadySaving(_, _) => ZIO.succeed(model)
end handleMessage
def view(model: Signal[Model]): HtmlElement[Msg] =
val workflow = model.map(_.workflow)
val name = workflow.map(_.current).field(Profile.Name)
val email = workflow.map(_.current).field(Profile.Email)
mainTag(
form(
idAttr := "profile-form",
Profile.Definition.onChange(Msg.Validate(_)),
Profile.Definition.onSubmit(Msg.Save(_)),
label(forId := name.id, "Name"),
name.text(name.validationAttributes),
label(forId := email.id, "Email"),
email.email(email.validationAttributes),
button(
typ := "submit",
disabled := workflow.map(_.isSaving),
workflow.map(current => if current.isSaving then "Saving..." else "Save")
),
button(typ := "button", on.click(Msg.Reset), "Reset")
),
workflow.map(_.failureForCurrentRevision).option { failure =>
div(
role := "alert",
p(failure.map(_.message)),
button(typ := "button", on.click(Msg.DismissFailure), "Dismiss")
)
},
workflow.map(_.isDirty).when(p("You have unsaved changes."))
)
end view
end ProfileSaveLiveView
object ProfileSaveLiveView:
val SaveKey = AsyncKey[Unit]("save-profile")
final case class Model(workflow: Profile.Definition.Workflow[SaveError])
enum Msg:
case Validate(event: Profile.Definition.Event)
case Save(event: Profile.Definition.Event)
case Saved(token: FormSubmissionToken, result: LiveAsyncResult[Unit])
case DismissFailure
case ResetView source (documentation/fixtures/guides/src/forms/ProfileSaveLiveView.scala:2-103)
The form save workflow example provides deterministic controls for invalid and overlapping starts, success, failure, cancellation, revision-specific failure feedback and dismissal, edits during save, stale replay, blocked reset, and baseline advancement.
Related tasks
Warn before leaving a form with unsaved changes with Navigation guards.
Cancel or replace background work with Asynchronous work.
Test submission and validation with Testing.