DEV Community

Cover image for Designing Post-Submit Form Workflows as a State Machine
Lovanaut
Lovanaut

Posted on

Designing Post-Submit Form Workflows as a State Machine

Most form implementations treat submission as the finish line.

Validate the payload.

Insert a row.

Return a success screen.

Maybe send an email.

Maybe post to Slack.

That works until the form becomes operational.

The first support request asks why the auto-reply did not arrive. A sales lead appears in Slack, but nobody owns it. A test submission pollutes a dashboard. A webhook retries and posts the same notification twice. A respondent sees "booking confirmed" when the team only meant "request received."

At that point, the form submission handler is no longer just a handler. It is the entry point to a workflow.

I have been building FORMLOVA, a form-operations product where people can create forms, review responses, configure emails, sync records, trigger notifications, and manage response status through chat and MCP clients.

The product lesson has been consistent:

A form response is not just data. It is an event that needs a lifecycle.

This post describes the state model I use when designing post-submit form workflows.

The Common Mistake: Mixing Outcome, Notification, and Ownership

Here is the typical first version:

async function submitForm(input: FormInput) {
  const response = await db.responses.insert(input);

  await sendAutoReply(response);
  await postToSlack(response);

  return {
    ok: true,
    message: "Thanks, your submission was received.",
  };
}
Enter fullscreen mode Exit fullscreen mode

It looks fine.

But this function mixes several different facts:

  • the response was saved
  • the respondent saw a confirmation message
  • an auto-reply was attempted
  • Slack was notified
  • a team member noticed it
  • someone owns the next action
  • the work is done

Those are not the same state.

The bug is not the code style. The bug is the mental model.

Use Separate States for Separate Questions

When a form response arrives, I want to answer five questions independently.

1. Is the response safely recorded?
2. Has the respondent been acknowledged?
3. Has the team been notified?
4. Does a human or workflow own the next action?
5. Is the response still open, done, or excluded?
Enter fullscreen mode Exit fullscreen mode

That maps to a simple workflow model.

type ResponseStatus =
  | "new"
  | "in_progress"
  | "done"
  | "excluded";

type AcknowledgementState =
  | "not_required"
  | "pending"
  | "sent"
  | "failed";

type NotificationState =
  | "not_required"
  | "pending"
  | "sent"
  | "failed";

type FormResponseWorkflow = {
  responseId: string;
  responseStatus: ResponseStatus;
  acknowledgement: AcknowledgementState;
  notification: NotificationState;
  ownerId: string | null;
  lastEventAt: string;
};
Enter fullscreen mode Exit fullscreen mode

You do not need exactly these names.

The important part is that a Slack message is not the response status. An email send attempt is not inbox delivery. A thank-you page is not a team handoff.

Each question gets its own state.

Treat the Database Insert as the First Committed Event

The first reliable transition is the response record.

type ResponseSubmitted = {
  type: "response.submitted";
  responseId: string;
  formId: string;
  submittedAt: string;
};
Enter fullscreen mode Exit fullscreen mode

After this event exists, other work can happen.

The respondent-facing confirmation screen can render immediately. The team-facing notification can run asynchronously. The auto-reply can be retried. Analytics can update later.

The submission itself should not depend on Slack, email, or an LLM call.

For a production form, this ordering matters:

critical path:
  validate
  rate limit
  save response
  return confirmation

post-submit work:
  send auto-reply
  post notification
  sync Sheets or CRM
  classify response
  update analytics
  trigger follow-up workflow
Enter fullscreen mode Exit fullscreen mode

If Slack is down, the response should still be collected.

If an email provider times out, the respondent should not lose their submission.

If classification fails, the original message should still exist.

Side Effects Need Idempotency Keys

Once you move post-submit work outside the critical path, retries become normal.

Retries are good.

Duplicate emails and duplicate Slack posts are not.

Every side effect should have an operation key.

function operationKey(
  responseId: string,
  operation: "auto_reply" | "slack_notification" | "sheets_sync",
  version = 1,
) {
  return `response:${responseId}:${operation}:v${version}`;
}
Enter fullscreen mode Exit fullscreen mode

Before executing a side effect, check whether the operation already succeeded.

async function runOnce(key: string, work: () => Promise<void>) {
  const existing = await db.workflowOperations.findUnique({ key });
  if (existing?.status === "succeeded") return;

  await db.workflowOperations.upsert({
    key,
    status: "running",
    startedAt: new Date().toISOString(),
  });

  try {
    await work();
    await db.workflowOperations.update({
      key,
      status: "succeeded",
      completedAt: new Date().toISOString(),
    });
  } catch (error) {
    await db.workflowOperations.update({
      key,
      status: "failed",
      errorMessage: String(error),
    });
    throw error;
  }
}
Enter fullscreen mode Exit fullscreen mode

This pattern is boring, but it removes a lot of operational ambiguity.

You can retry failed jobs without asking whether the respondent will receive two confirmation emails.

The Thank-You Page Is a UI State, Not a Delivery State

A thank-you page answers one question:

Did the form accept my submission?
Enter fullscreen mode Exit fullscreen mode

It does not prove that an email was delivered.

It does not prove that a human read the inquiry.

It does not prove that a booking was confirmed.

That distinction should show up in the copy.

Bad:

Your booking is complete.
Enter fullscreen mode Exit fullscreen mode

Better:

Your preferred appointment time has been received.
This does not confirm the booking yet.
We will check availability and send the confirmed time by email.
Enter fullscreen mode Exit fullscreen mode

The UI should match the workflow state.

If the response is only recorded, say it was received.

If the booking is not confirmed, do not call it confirmed.

If an auto-reply will be sent, say to check the email address and spam folder, but do not imply delivery is guaranteed.

Auto-Reply Enabled Is Not the Same as Auto-Reply Sent

Auto-replies create a second common state bug.

Teams often ask:

Is the auto-reply on?
Enter fullscreen mode Exit fullscreen mode

That is a configuration question.

When debugging, the better questions are:

Was this response eligible for an auto-reply?
Was the recipient email field present?
Was a send operation created?
Did the email provider accept the message?
Did the provider report a bounce?
Did the respondent find it in the inbox?
Enter fullscreen mode Exit fullscreen mode

These should not collapse into one boolean.

type AutoReplyTrace = {
  enabledForForm: boolean;
  recipientField: string | null;
  recipientEmail: string | null;
  eligible: boolean;
  operationStatus: "not_created" | "pending" | "sent" | "failed";
  providerMessageId: string | null;
  providerState: "unknown" | "accepted" | "bounced" | "complained";
};
Enter fullscreen mode Exit fullscreen mode

You do not need to expose all of this to the user.

But the system should be able to tell the difference between "the feature is enabled" and "this respondent received a usable email."

Slack Notification Is Not Assignment

Slack is a great place to notice work.

It is a weak place to prove work is done.

A message in a channel can mean many things:

  • the response arrived
  • someone saw it
  • someone reacted to it
  • someone replied in a thread
  • someone assumed another person was handling it

None of those is the same as ownership.

Keep the owner and status on the response record.

type ResponseAssignment = {
  responseId: string;
  ownerId: string | null;
  status: "new" | "in_progress" | "done" | "excluded";
  assignedAt: string | null;
  completedAt: string | null;
};
Enter fullscreen mode Exit fullscreen mode

The Slack message should point back to that record.

New pricing inquiry
Company: Example Co
Summary: Interested in workflow automation for event registrations
Status: New
Owner: Unassigned
Open response: https://...
Enter fullscreen mode Exit fullscreen mode

The channel is the alert surface.

The response record is the operational surface.

Model Exclusions Explicitly

Not every response deserves the same workflow.

A sales pitch should not page the team.

A test submission should not appear in conversion reporting.

A duplicate should not create a second support task.

Do not hide those cases by deleting rows.

Use an explicit status or label.

type ExclusionReason =
  | "sales_pitch"
  | "test_submission"
  | "duplicate"
  | "irrelevant"
  | "manual";

type ResponseExclusion = {
  responseId: string;
  excluded: boolean;
  reason: ExclusionReason | null;
  excludedBy: "system" | "human" | null;
};
Enter fullscreen mode Exit fullscreen mode

This matters for analytics.

It also matters for trust. When someone asks why a response did not trigger Slack or why it is missing from a report, the system can explain the decision.

Do Not Put Destructive Operations Behind Model Confidence Alone

Some post-submit actions are low risk.

Posting a notification is usually reversible enough.

Changing a status from new to in_progress can be corrected.

Sending an email to 500 people is different.

Deleting responses is different.

Publishing a form publicly is different.

For high-impact operations, use server-side confirmation.

type SafetyLevel =
  | "read"
  | "reversible_write"
  | "respondent_visible"
  | "irreversible_or_bulk";
Enter fullscreen mode Exit fullscreen mode

For the highest tier, the first tool call should return a confirmation summary instead of executing.

type ConfirmationPrompt = {
  action: "send_bulk_email";
  recipientCount: number;
  subjectPreview: string;
  firstLinePreview: string;
  expiresAt: string;
  token: string;
};
Enter fullscreen mode Exit fullscreen mode

Then execution requires an explicit confirmation token.

This is not a prompt instruction.

It is a product boundary.

If the operation can affect respondents at scale, the server should enforce the pause.

A Practical Post-Submit Workflow Shape

Here is the shape I would start with for a serious form.

async function handleResponseSubmitted(event: ResponseSubmitted) {
  await runOnce(
    operationKey(event.responseId, "auto_reply"),
    () => sendAutoReplyIfEligible(event.responseId),
  );

  await runOnce(
    operationKey(event.responseId, "slack_notification"),
    () => notifyTeamIfEligible(event.responseId),
  );

  await runOnce(
    operationKey(event.responseId, "sheets_sync"),
    () => syncResponseRecord(event.responseId),
  );

  await updateWorkflowSummary(event.responseId);
}
Enter fullscreen mode Exit fullscreen mode

Inside each function, keep decisions explicit.

async function notifyTeamIfEligible(responseId: string) {
  const response = await loadResponse(responseId);

  if (response.status === "excluded") return;
  if (response.category === "test") return;
  if (!["pricing", "implementation", "support"].includes(response.category)) {
    return;
  }

  await postSlackMessage({
    channel: "#inquiries",
    text: buildResponseSummary(response),
    responseUrl: response.url,
    status: response.status,
    owner: response.ownerName ?? "Unassigned",
  });
}
Enter fullscreen mode Exit fullscreen mode

The actual code in your app will depend on your queue, database, and email provider.

The important part is that each side effect can be reasoned about independently.

What This Looks Like Through MCP

MCP makes this more interesting because an AI client can ask product-shaped questions:

Show me new pricing inquiries that were not sales pitches.
Enter fullscreen mode Exit fullscreen mode
Send a reminder to webinar registrants who have not confirmed,
but show me the recipient count before sending.
Enter fullscreen mode Exit fullscreen mode
Create a Slack notification workflow for high-intent inquiries,
and keep Google Sheets as the record.
Enter fullscreen mode Exit fullscreen mode

If the product only exposes endpoint-shaped tools, the model has to reconstruct the workflow every time.

If the product exposes operations-shaped tools, the server can carry the domain boundaries:

  • which responses are excluded
  • which actions need confirmation
  • which status transitions are valid
  • which fields can be shown in Slack
  • which side effects have already succeeded

For FORMLOVA, that is the distinction between "AI can make a form" and "AI can help operate the form after it is live."

The Checklist I Use

Before shipping a post-submit workflow, I ask:

[ ] Is the response saved before side effects run?
[ ] Can the success screen be truthful even if email fails?
[ ] Is each side effect idempotent?
[ ] Can failed side effects be retried safely?
[ ] Is Slack treated as notification, not assignment?
[ ] Does the response record have owner and status?
[ ] Are excluded responses labeled instead of deleted?
[ ] Are respondent-visible or bulk actions confirmation-gated?
[ ] Can the system explain why a response did or did not trigger an action?
Enter fullscreen mode Exit fullscreen mode

If those answers are clear, the workflow is usually maintainable.

If they are not, the form may work in the demo but leak in operations.

Closing

The submit button is not the end of a form workflow.

It is the first committed event.

After that, the product needs to acknowledge the respondent, notify the team, create a record, assign ownership, track status, retry side effects, and protect high-impact actions with confirmation.

Treating all of that as one boolean called submitted is what makes simple forms become operational debt.

Model the lifecycle instead.

Related FORMLOVA Guides

Top comments (0)