Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

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 FormWorkflow. 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:

Source
scala
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))

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:

Source
scala
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)

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:

Source
scala
case Msg.DismissFailure =>
  ZIO.succeed(model.copy(workflow = model.workflow.dismissFailure))

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:

Source
scala
case Msg.Reset =>
  model.workflow.reset match
    case FormWorkflowReset.Reset(next)         => ZIO.succeed(model.copy(workflow = next))
    case FormWorkflowReset.AlreadySaving(_, _) => ZIO.succeed(model)

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

Source
scala
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 Reset

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.