A user writes a detailed bug report, presses Send, and sees a disabled button. Then the request takes longer than expected.
Did the report arrive? Should they refresh? Will pressing again create two tickets?
This is the tension a pending UI cannot resolve. Disabling the button improves the interaction, but it does not prove that the server accepted the report. A reliable support form needs a protocol for retries, duplicate requests, lost responses, and durable acknowledgement.
In this tutorial, we will build that protocol around four guarantees:
- Every logical submission has a stable idempotency key.
- Retrying the same submission returns the same receipt.
- The UI distinguishes “sending” from “received.”
- Notifications cannot disappear between the database commit and the support queue.
React 19's useActionState will represent the browser-side transition. PostgreSQL will enforce the actual delivery semantics.
Define “submitted” before writing the component
Treat the form as a small distributed system. The browser, API, database, and notification destination can each fail independently.
These are useful acceptance cases:
| Event | Expected result |
|---|---|
| User double-clicks | One logical support request |
| Browser retries after a timeout | The original receipt is returned |
| API commits but its response is lost | A retry confirms the committed request |
| Notification delivery fails | The request remains stored and notification can be retried |
| User changes the message after an uncertain attempt | The API rejects reuse of the old key |
| React component unmounts | The server-side request remains valid |
The disabled button only helps with the first row, and only within one mounted component. The rest requires server-side design.
Model a request, a receipt, and notification work
Create two PostgreSQL tables:
CREATE TABLE support_requests (
id uuid PRIMARY KEY,
submission_key uuid NOT NULL UNIQUE,
receipt_token uuid NOT NULL UNIQUE,
payload_fingerprint text NOT NULL,
email text,
message text NOT NULL,
page_url text,
status text NOT NULL DEFAULT 'received'
CHECK (status IN ('received', 'reviewing', 'waiting_on_reporter', 'resolved')),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE support_notification_outbox (
request_id uuid PRIMARY KEY REFERENCES support_requests(id),
attempts integer NOT NULL DEFAULT 0,
available_at timestamptz NOT NULL DEFAULT now(),
delivered_at timestamptz,
last_error text
);
Enter fullscreen mode Exit fullscreen mode
The identifiers have different jobs:
-
submission_keyidentifies one browser-side submission and deduplicates retries. -
receipt_tokengives the reporter an opaque value for checking delivery. -
idis the internal request identifier. -
payload_fingerprintprevents one idempotency key from being reused for different content.
The outbox row represents notification work. It is created in the same transaction as the request, so a process crash cannot leave you with a committed request that nobody knows should be routed.
Make the API idempotent
The following Express handler uses Node's built-in crypto module and pg. The omitted validateSupportBody function should enforce UUID syntax, an email policy, allowed URL schemes, and strict length limits before the transaction begins.
import crypto from 'node:crypto';
import express from 'express';
import { pool } from './db.js';
const app = express();
app.use(express.json({ limit: '32kb' }));
function fingerprint({ email, message, pageUrl }) {
const canonical = JSON.stringify({
email: email?.trim().toLowerCase() || null,
message: message.trim(),
pageUrl: pageUrl || null
});
return crypto.createHash('sha256').update(canonical).digest('hex');
}
app.post('/api/support', async (req, res, next) => {
const parsed = validateSupportBody(req.body);
if (!parsed.ok) {
return res.status(422).json({
error: 'validation_failed',
fields: parsed.fields
});
}
const input = parsed.value;
const payloadFingerprint = fingerprint(input);
const client = await pool.connect();
try {
await client.query('BEGIN');
const inserted = await client.query(
`INSERT INTO support_requests (
id, submission_key, receipt_token, payload_fingerprint,
email, message, page_url
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (submission_key) DO NOTHING
RETURNING id, receipt_token, payload_fingerprint, status, created_at`,
[
crypto.randomUUID(),
input.submissionKey,
crypto.randomUUID(),
payloadFingerprint,
input.email || null,
input.message,
input.pageUrl || null
]
);
let request;
let created = false;
if (inserted.rowCount === 1) {
request = inserted.rows[0];
created = true;
await client.query(
`INSERT INTO support_notification_outbox (request_id)
VALUES ($1)`,
[request.id]
);
} else {
const existing = await client.query(
`SELECT id, receipt_token, payload_fingerprint, status, created_at
FROM support_requests
WHERE submission_key = $1`,
[input.submissionKey]
);
request = existing.rows[0];
if (request.payload_fingerprint !== payloadFingerprint) {
await client.query('ROLLBACK');
return res.status(409).json({ error: 'idempotency_key_reused' });
}
}
await client.query('COMMIT');
return res.status(created ? 201 : 200).json({
receipt: request.receipt_token,
status: request.status,
receivedAt: request.created_at
});
} catch (error) {
await client.query('ROLLBACK');
next(error);
} finally {
client.release();
}
});
Enter fullscreen mode Exit fullscreen mode
ON CONFLICT DO NOTHING is the important boundary. Two concurrent requests can reach the API, but PostgreSQL decides which one creates the row.
A retry runs a second query after the conflicting insert has completed. If its fingerprint matches, the API returns the existing receipt. If the content differs, it returns 409 rather than silently associating new text with an old acknowledgement.
Represent the protocol with useActionState
Now the React component can expose meaningful states instead of one isSubmitting boolean:
-
idle: no attempt yet -
invalid: the server rejected form fields -
transport-error: delivery is unknown and the same submission can be retried -
conflict: the content changed while reusing an old key -
received: the server returned a durable receipt
Store the idempotency key in sessionStorage. That preserves it across a refresh in the same tab, which matters when the database committed but the response never reached the browser.
'use client';
import { useActionState } from 'react';
const KEY_NAME = 'support-form-submission-key';
function getOrCreateSubmissionKey() {
let key = sessionStorage.getItem(KEY_NAME);
if (!key) {
key = crypto.randomUUID();
sessionStorage.setItem(KEY_NAME, key);
}
return key;
}
function clearSubmissionKey() {
sessionStorage.removeItem(KEY_NAME);
}
const initialState = {
phase: 'idle',
error: null,
fields: {},
values: { email: '', message: '' }
};
async function submitSupport(previousState, formData) {
const values = {
email: String(formData.get('email') || ''),
message: String(formData.get('message') || '')
};
try {
const response = await fetch('/api/support', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...values,
pageUrl: window.location.href,
submissionKey: getOrCreateSubmissionKey()
})
});
const result = await response.json();
if (response.status === 422) {
return {
phase: 'invalid',
error: 'Please check the highlighted fields.',
fields: result.fields,
values
};
}
if (response.status === 409) {
return {
phase: 'conflict',
error: 'This draft changed after an uncertain submission. Submit it as a new request.',
fields: {},
values
};
}
if (!response.ok) {
throw new Error('Unexpected server response');
}
clearSubmissionKey();
return {
phase: 'received',
receipt: result.receipt,
receivedAt: result.receivedAt,
fields: {},
values
};
} catch {
return {
phase: 'transport-error',
error: 'Delivery could not be confirmed. Retry without editing to check the same submission.',
fields: {},
values
};
}
}
export default function SupportForm() {
const [state, formAction, pending] = useActionState(
submitSupport,
initialState
);
if (state.phase === 'received') {
return (
<section aria-live="polite">
<h2>Report received</h2>
<p>Your receipt is <code>{state.receipt}</code>.</p>
<a href={`/support/status/${state.receipt}`}>
Check request status
</a>
</section>
);
}
function handleInput() {
if (
state.phase === 'transport-error' ||
state.phase === 'conflict'
) {
clearSubmissionKey();
}
}
return (
<form action={formAction} onInput={handleInput}>
<label>
Email for follow-up
<input
name="email"
type="email"
defaultValue={state.values.email}
aria-describedby={state.fields?.email ? 'email-error' : undefined}
/>
</label>
{state.fields?.email && (
<p id="email-error" role="alert">{state.fields.email}</p>
)}
<label>
What happened?
<textarea
name="message"
required
minLength={20}
maxLength={5000}
defaultValue={state.values.message}
aria-describedby={state.fields?.message ? 'message-error' : undefined}
/>
</label>
{state.fields?.message && (
<p id="message-error" role="alert">{state.fields.message}</p>
)}
<button type="submit" disabled={pending}>
{pending ? 'Sending…' : 'Send report'}
</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
Enter fullscreen mode Exit fullscreen mode
The pending value still matters. It prevents accidental repeated interaction and gives immediate visual feedback. It simply is not being asked to provide a guarantee it cannot provide.
Notice the retry rule: after a transport error, submitting without editing reuses the key. Editing clears the key, so the changed report becomes a new logical submission.
Give the receipt a narrow status endpoint
A receipt is more useful when it can confirm the current state without exposing the report itself:
app.get('/api/support/status/:receipt', async (req, res) => {
const result = await pool.query(
`SELECT status, created_at, updated_at
FROM support_requests
WHERE receipt_token = $1`,
[req.params.receipt]
);
if (result.rowCount === 0) {
return res.status(404).json({ error: 'not_found' });
}
res.json({
status: result.rows[0].status,
receivedAt: result.rows[0].created_at,
updatedAt: result.rows[0].updated_at
});
});
Enter fullscreen mode Exit fullscreen mode
Do not return the original message or email from an unauthenticated receipt endpoint. An opaque token reduces guessing risk, but it should still be treated as a bearer secret.
The support team can move requests through the small status set using an authenticated internal tool. Keep those transitions human-owned. “Resolved” is a decision about the user's problem, not a synonym for “a classifier processed the text.”
Route notifications without coupling them to acknowledgement
A worker can claim pending outbox rows with FOR UPDATE SKIP LOCKED, send them to your support destination, and then set delivered_at.
SELECT request_id
FROM support_notification_outbox
WHERE delivered_at IS NULL
AND available_at <= now()
ORDER BY available_at
FOR UPDATE SKIP LOCKED
LIMIT 20;
Enter fullscreen mode Exit fullscreen mode
On failure, increment attempts, store a bounded error description, and move available_at forward with backoff. On success, mark delivered_at.
Notification delivery must be idempotent too. Include request_id as the external deduplication key when the destination supports it. Otherwise, accept that an operator might occasionally see a duplicate notification while the stored support request remains singular.
This creates an important semantic distinction:
- The receipt means the report is durably stored.
- The outbox means routing work is durable.
- Neither means a human has reviewed the report.
That wording is less exciting than “instant support,” but it is operationally honest.
Where AI can help—and where it should stop
An AI model can propose a summary, category, or likely owning area after the request is stored. That can reduce the reading needed to begin triage, but the output remains a probabilistic suggestion rather than proof that the issue was understood.
Store suggestions separately from human decisions:
ALTER TABLE support_requests
ADD COLUMN suggested_category text,
ADD COLUMN suggested_summary text,
ADD COLUMN suggestion_model text,
ADD COLUMN category_confirmed_by text;
Enter fullscreen mode Exit fullscreen mode
A safe boundary is:
- Persist and acknowledge the unmodified user report first.
- Generate optional suggestions asynchronously.
- Show the original text beside every suggestion.
- Require a human to confirm ownership, priority, and resolution.
- Keep the workflow functional when the model is unavailable.
The underlying human concern is usually not whether AI can produce a plausible label. It is whether the team can trust that an urgent or unusual request will not be hidden by that label. Preserve the original report and make uncertain categorization visible.
If an assistant generates integration code for this step, independently verify package names, package ownership, and official documentation before installing anything. Generated dependency names are not a supply-chain trust decision.
When a hosted widget is the better trade-off
The custom implementation is appropriate when you need your own receipt semantics, database retention rules, status page, or internal routing logic. It also creates ongoing work: abuse controls, accessibility testing, notification retries, privacy handling, and support tooling.
For a small project, a hosted contact layer may be the more proportionate choice. Knocket is one implementation example: it provides an embeddable live-chat widget, a shareable contact page, and a unified inbox. Website installation uses a generated script tag and does not require a custom backend.
A minimal integration shape is:
<!-- Use the exact generated script tag from your project dashboard. -->
<script src="PASTE_THE_GENERATED_WIDGET_SCRIPT_URL_HERE"></script>
Enter fullscreen mode Exit fullscreen mode
Do not deploy the placeholder literally. Copy the generated script tag rather than guessing a package or CDN path.
Visitors can start a chat without creating an account. Messages can be routed to Telegram, and a quoted Telegram reply can be delivered back to the website visitor. That is useful when the team's real operational destination is already Telegram rather than a custom triage application.
The decision is not “widget versus good engineering.” It is where you want the engineering boundary to sit:
| Requirement | Custom receipt workflow | Hosted widget |
|---|---|---|
| Custom idempotency and status semantics | Strong fit | Verify product behavior first |
| No custom support backend | Poor fit | Strong fit |
| Full control over stored fields | Strong fit | Review the service's model |
| Fast conversational follow-up | Additional work | Strong fit |
| Custom notification and retention policies | Strong fit | May be constrained |
Failure modes worth testing deliberately
The database commits and the browser times out
Keep the key in session storage until a receipt is parsed. Retrying must return the original receipt.
The reporter edits after an uncertain result
Clear the old key on edit. The server-side fingerprint remains a final guard and should return 409 if the client gets this wrong.
The notifier is unavailable
Do not roll back or hide an already accepted report. Retry the outbox independently and monitor its oldest undelivered row.
A bot reuses many valid keys
Idempotency is not abuse prevention. Add request-size limits, rate limiting, origin checks where appropriate, and a honeypot or challenge based on observed abuse. Avoid collecting browser metadata merely because it is available.
A receipt URL leaks
Return only non-sensitive status information. For private conversations or report content, require authentication or a separate verified return channel.
JavaScript never loads
This client-side fetch implementation requires JavaScript. If progressive enhancement is a requirement, move the action to a framework-supported server action or provide a conventional HTML form endpoint that implements the same idempotency contract.
Verification checklist
Before shipping, verify the protocol rather than only clicking through the happy path:
- [ ] Two concurrent requests with the same key create one database row.
- [ ] A repeated request returns the same receipt.
- [ ] Reusing a key with different content returns
409. - [ ] Killing the API after commit but before response still allows recovery.
- [ ] Notification failure leaves a retryable outbox row.
- [ ] The success message appears only after a receipt is returned.
- [ ] The status endpoint does not expose email or message text.
- [ ] Keyboard and screen-reader users receive pending, error, and success feedback.
- [ ] Validation, rate limits, and body-size limits exist on the server.
- [ ] AI categorization can fail without blocking acknowledgement or human triage.
A disabled submit button answers, “Can this component start another action right now?” A receipt answers the question the reporter actually cares about: “Does the system have my report?”
Build the latter as a server-enforced contract. Then let React communicate that contract clearly.
Disclosure: I work on Knocket, so treat it as one implementation example rather than a neutral recommendation.
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.