Approval flows look simple in product meetings, then get weird fast in code. A reviewer clicks Approve, the UI updates, a follow-up email should go out, and somehow the same action fires twice after a refetch or tab restore. I have seen this happen in otherwise clean React apps, and it nearly always comes from mixing user intent with rendering side effects.
The fix that holds up best is boring in a good way: treat "approve request" as an explicit event, then let the backend own whether an email should be sent. Once I started separating those two concerns, the frontend got easier to reason about and the backend logs started making sense again.
Why approval flows trigger duplicate emails
The risky version usually looks like this:
- User clicks Approve.
- React state changes to
approved. - A
useEffectnotices that status changed. - The effect calls an endpoint that sends the email.
That feels tidy at first, but it breaks when the component remounts, data rehydrates, or another state sync runs just after the mutation. The render tree is not a workflow engine. It is very good at rendering what is true now, and kind of bad at deciding what should happen exactly once.
This is close to the same trap I wrote about in safer React email state handling. If the UI state becomes the trigger for a one-time side effect, duplicate sends are not a bug you "might" get. You will get them eventualy.
One more practical issue: product teams often add retries later. A second click, a websocket reconnect, or a stale optimistic patch can all replay the same transition. If email delivery is coupled to useEffect, the system gets fragile real quick.
Model the action as an event, not an effect
What has worked better for me is this:
- The button dispatches an approval command.
- The server records an approval event with an idempotency key.
- The notification worker sends the email only if that event is new.
- The UI re-renders from fresh server state, but does not decide whether mail goes out.
That is a small mental shift, but it clears up a lot. React handles interaction and feedback. Node.js handles side effects and delivery rules. Your database becomes the source of truth for whether the approval email was already scheduled.
In other words, the client says "this user tried to approve request 123 with action token abc". The server decides whether that command is valid, fresh, and worth notifying on. This is not overengineering, it is the point where the flow stops being haunted.
A small React and Node.js implementation
On the client, I like keeping the mutation direct and dumb:
async function approveRequest(requestId: string) {
const idempotencyKey = crypto.randomUUID();
const res = await fetch(`/api/approvals/${requestId}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-idempotency-key": idempotencyKey
},
body: JSON.stringify({ decision: "approved" })
});
if (!res.ok) {
throw new Error("Approval failed");
}
return res.json();
}
No email logic in the component. No useEffect watching status. No "if approved then send" branch hiding in render land.
On the server, the handler can make the send idempotent:
app.post("/api/approvals/:id", async (req, res) => {
const requestId = req.params.id;
const key = req.header("x-idempotency-key");
const alreadyProcessed = await approvalsRepo.hasProcessed(key);
if (alreadyProcessed) {
return res.json({ ok: true, duplicate: true });
}
await db.transaction(async (tx) => {
await tx.approvals.markApproved(requestId);
await tx.idempotencyKeys.insert(key);
await tx.outbox.insert({
type: "approval_email_requested",
requestId
});
});
res.json({ ok: true });
});
This pattern also plays nicely with outbox delivery. If your mail worker crashes after the transaction commits, the email event is still there. If the client retries, the API can say "already handled" and move on. It sounds simple because it is, and that is kinda the appeal.
I also like it because it keeps UX decisions local. You can optimistically disable the button, show a toast, or roll back the view without changing how email delivery is decided. Cleaner seam, fewer spooky bugs.
How I test the flow before release
I do not test this by clicking around and hoping to catch a double send. I want one check for the API contract and one check for the rendered UX.
For the API layer, I use the same kind of thinking as the API fixture pattern for email regression checks: stable input, stable identifiers, and an assertion on the event produced, not just the HTTP response. If the first request writes one outbox row and the second request writes zero, I know the backend contract is doing its job.
For the UI, I usually test three things:
- The Approve button disables while the request is in flight.
- A retry does not create a second logical approval.
- Reloading the page after success does not retrigger email work.
If you keep temporary inbox checks in staging, document them clearly. Engineers will search with rough phrases like tempail or temp org mail during a rushed release, so a tiny bit of human messiness in docs is honestly fine. Not pretty, but real.
One useful benchmark here is idempotency behavior, not raw speed. In the 2024 State of JavaScript survey, developer experience and reliability still ranked as major adoption drivers for tooling choices, which matches what most teams feel day to day: nobody remembers a request that was 40 ms faster, but everybody remembers a double email that hit a customer. Source: https://2024.stateofjs.com/en-US
Q&A
Should I never use useEffect for email-related work?
I would not use it for one-time business actions. useEffect is still fine for syncing subscriptions, observers, or browser APIs. It is just a poor place to decide whether a customer-facing notification should happen once.
Is an outbox table too much for a small app?
Not always. If approval emails matter to users or ops, an outbox is often the smallest reliable thing. Without it, teams end up rebuilding half the same safety in ad hoc retry code anyway.
What is the fastest win if I cannot refactor much yet?
Add an idempotency key to the approval endpoint first. That change is often small, and it reduces the blast radius even before the frontend is cleaned up. It is not the whole fix, but it gets you unstuck pretty fast.
Top comments (0)