Skip to content

Mirroring the Status Transition Map in the Frontend

Adityo Guni Waluyo

A tiny frontend mirror of the backend transition map: one union type, one map, and impossible options disabled before the click.

TL;DR

I refactored the tangled status logic into a clean switch that maps six statuses to four display stages. A lean NEXT_STATUSES map only decides which options light up, leaving the backend as the true authority that returns 409s. I added type guards, fallback labels for unknown statuses, and real disabling beyond just aria-disabled.

I opened layanan-flow.ts that afternoon, intending to add one new status from the backend. What I found: the flowStageOf function contained a three-level nested ternary, with one branch for each old status. On the adjacent screen, the admin form was still cheerfully offering a transition button to a status that no longer existed. Even when clicked, it wasn't our UI that responded, but the backend, with a 409.

My first guess: just copy the entire backend transition rules to the frontend. A full duplicate, so nothing gets missed. The longer I thought about it, the worse it felt: now there were two places both claiming to be the source of truth.

What I ended up writing was far leaner. The LayananStatus union type lists six statuses as strings, from submission to completion: verification and processing in the middle, approved, rejected, and completed at the end. Then a single NEXT_STATUSES map that answers only one question: from this status, which options are allowed to light up in the UI. That's it. The authority for valid transitions remains with the backend; if anyone gets bold, it's the one throwing the 409.

Not a logic duplication

The Redux Style Guide sums up the reason in one sentence: keep state minimal and derive additional values when needed, because a single source of truth makes data flow predictable [2]. The map in my frontend is clearly not the source of truth. It is a derived component read during render to compute active options, not copied into a separate state [4] — and left to die if the backend changes. The risk I accept: manual synchronization. A new status does have to pass through this file first, and precisely through that gate, code review becomes a natural checkpoint.

Ternary to switch, wild strings to union

flowStageOf is now a plain switch that maps six statuses to four display stages typed as FlowStage: submission, verification, processing, and decision. The isLayananStatus type guard allows TypeScript to perform narrowing safely [1]; weird strings from the outside won't get any label at all. Clean labels per status are stored separately in a label map, so copywriting revisions don't touch the logic. The three-level ternary? Retired without ceremony.

One old part I deliberately haven't deleted yet. The auto-note constant for pending tickets is still used by two components being rewritten in the next task, so it stays temporarily in the same file, complete with a note on when it can be removed. I'm growing more convinced of this pattern: honest type migration is gradual. The important thing is that every remnant of the transition leaves a written trace, not just relying on the memory of someone who might take leave tomorrow.

One small detail that makes this component durable: the label display function accepts any string. Recognized values immediately get a clean label, the rest are displayed raw as-is. The page never goes blank just because the backend sent a status that hasn't been registered in the frontend yet.

The attribute that doesn't reject clicks

The part I misunderstood for quite a while: aria-disabled. I thought once it was attached, the button was automatically safe. Turns out that attribute is purely semantic; the code itself still has to disable the functionality [3]. The workflow became this: NEXT_STATUSES disables impossible options in the user's eyes, the handler still double-checks before sending, and the backend rejects anything that manages to slip through. Three layers, and the most durable one is that very last layer.

In the medium term, I want to offload this manual sync to a single source: types from the contract. In the same repo, the OpenAPI schema is already generated into types [6] for its endpoints, and the same path can be used for the status vocabulary. As for time data alone, a string with a messed-up format immediately ends up as NaN [5] — especially since all UI decisions in this component hinge on a single status string. That's why in this commit I chose to be explicit: six named statuses, one human-readable map, with no illusion that the frontend is making the decisions.

Sources

Related articles