DEV Community

Cover image for Beyond CRUD: Anatomy of a real admin workflow in React
Coreola
Coreola

Posted on

Beyond CRUD: Anatomy of a real admin workflow in React

Patterns are abstract until they meet a real workflow with statuses, sub-resources, and a decision that lives separately from status. Here's what falls out when you actually build one.

The previous three posts in this series were about patterns: the floor problem, tables, and permissions. Each walked through one floor item with the same five-part failure pattern.

This post is different. It's the case study. Because patterns are abstract until you build them against something real, and "real" doesn't mean three CRUD screens — it means a workflow with statuses, sub-resources, ownership, audit timelines, and a decision flow that runs orthogonally to status.

This post walks through five design decisions that only become visible when an admin module stops being CRUD: separating status from decision, backend-owned transitions, first-class sub-resources, operator queues, and composable detail pages.

Here's what an actual workflow module — the Assessments module from Coreola — looks like, and the five design decisions you can't make without testing against the real thing.

What the workflow does

An Assessment is a structured review of something: a third-party vendor, a control set, a regulatory framework. The product has to model it from intake through completion, with findings raised against it, evidence requested from internal and external parties, an audit trail, and a decision that's separate from "what state is the assessment in right now."

The shape, at a glance:

Assessment
 ├─ Status lifecycle      (11 operational states)
 ├─ Decision lifecycle    (5 outcome states, orthogonal to status)
 ├─ Findings              (sub-resource, own 5-state status)
 ├─ Evidence requests     (sub-resource, own 6-state status)
 └─ Activity timeline     (sub-resource, audit log)
Enter fullscreen mode Exit fullscreen mode

The entity has 11 workflow statuses:

draft → intake → in_review → waiting_for_evidence
  → remediation_in_progress → blocked → approved
  → approved_with_exceptions → rejected → completed → archived
Enter fullscreen mode Exit fullscreen mode

It has three sub-resources, each with their own lifecycle:

  • Findings — issues raised during review, with their own 5-state status (open, accepted_risk, in_progress, resolved, closed), 4 severity levels, 8 categories, and links to the evidence that resolves them.
  • Evidence requests — documentation asks sent to recipients (sometimes internal, sometimes external), with their own 6-state status (requested, in_review, received, rejected, cancelled, expired) and three time dimensions tracked separately.
  • Activity — audit/timeline entries, each with a type and an actor.

And it has a decision — pending, approved, approved_with_conditions, rejected, escalated — which lives as a separate field on the assessment, independent of status.

That last point is where most templates fall over, so let's start there.

Decision #1: separate the decision dimension from the status dimension

Most CRUD templates have one enum for "where is this in the workflow." A naive Assessments model would put approved, rejected, and approved_with_exceptions in the same status enum as draft and in_review.

Look at what that costs:

  • The status field has to carry two semantically different ideas: "what's happening right now" (operational state) and "what was decided" (outcome state).
  • An assessment in remediation_in_progress doesn't have a decision yet, but if approved is also a status, the UI has to model "no decision yet" as a separate flag — or worse, infer it from the status string.
  • Re-opening a decision becomes a status transition. Now the audit log has to distinguish "we're working on this again because new evidence came in" from "we're working on this because we changed our minds about the previous decision."
  • Reporting becomes ambiguous. "How many assessments did we approve last quarter" requires knowing which status values count as approvals — and that list will change as new statuses get added.

Coreola's Assessment carries both:

status:   'draft' | 'intake' | 'in_review' | 'waiting_for_evidence' | ...   // 11 states
decision: 'pending' | 'approved' | 'approved_with_conditions' | 'rejected' | 'escalated'
Enter fullscreen mode Exit fullscreen mode

Status describes the operational dimension: where is this in the pipeline right now? Decision describes the outcome dimension: what was concluded? They're orthogonal. An assessment can be in_review with decision: pending. It can be completed with decision: approved_with_conditions. Reopening for new evidence changes status without touching decision. Recording a decision is its own mutation with its own dialog (SubmitDecisionDialogView) — it's not buried inside a status change.

This decision wouldn't have happened from looking at three CRUD screens. It only becomes obvious when you try to model a real review pipeline and run out of room in a single enum.

Decision #2: the state machine lives on the backend

11 statuses means there are far more than 11 possible transitions. Some are valid (in_reviewwaiting_for_evidence), some are nonsensical (completeddraft), and some are conditional (blockedapproved is fine only if there are no open findings).

You can model this on the frontend. Lots of teams do. They end up with a hand-maintained state machine in TypeScript that lists every valid from → to pair, with conditions. It works until the backend adds a status, or removes one, or changes a transition rule. Then there are two state machines — one on the backend, one in the frontend — that drift.

The pattern that survives is: the backend owns the transitions, the frontend asks for the current options.

In Coreola, the Assessments API has a meta endpoint. The frontend calls useGetAssessmentsMetaQuery() and gets back, among other things, the status_options available for the current state of the entity. The ChangeStatusDialogView renders only the statuses the backend says are reachable from here. Add a status on the backend, and it appears in the dropdown without a frontend change.

The frontend's responsibilities become much simpler:

  • Read the current status and the available transitions from meta.
  • Render the status with a Status component.
  • Trigger a transition via useUpdateAssessmentStatus.
  • Refetch automatically via RTK Query tag invalidation.

The frontend has no idea what the state machine looks like in full. It doesn't need to. The same pattern handles decisions, finding statuses, and evidence statuses — three more enums, none of which the frontend owns.

Decision #3: sub-resources are first-class

A naive model would treat findings and evidence requests as nested fields on the assessment object. Render them inside the details page as inline arrays. Mutate them by sending a whole-assessment update.

This works for two findings. It breaks the moment a finding has its own owner, its own due date, its own status transitions, its own permission gates, and its own deep link in a Slack message.

Coreola models each sub-resource as a real entity:

  • Its own type definition (AssessmentFinding, EvidenceRequest)
  • Its own API endpoints (list, create, update, delete)
  • Its own dialogs (NewFindingDialogView, ChangeFindingStatusDialogView, AssignFindingOwnerDialogView)
  • Its own status enum, separate from the parent's
  • Its own permission scoping (parent's assessment.update ability)
  • Its own deep-linkable URL

That last one matters more than it sounds. The details page route is:

'/collections/assessments/details/:assessmentId?/:resource?/:resourceId?'
Enter fullscreen mode Exit fullscreen mode

with resource constrained in routes.ts to 'finding' | 'evidence'. So /collections/assessments/details/123/finding/456 is a real, shareable, deep-linkable URL that opens assessment 123 with finding 456 focused. The same pattern works for evidence requests.

Now links to a specific finding actually work. Now the back button works between findings inside the same assessment. Now the URL is the entire identifier of "what is the user looking at," which is the same property tables relied on in post #2 and routes relied on in post #3.

The Activity sub-resource follows the same shape — it's not a derived list, it's a proper entity (AssessmentActivity) with its own API. You get audit logging without instrumenting every mutation site, because activity is a first-class thing the backend records and the frontend reads.

Decision #4: the queue is a different view, not a filtered list

When product asks for an "operator queue," the temptation is to add a few preset filters to the list view and call it done. "It's just the list with status filters preset."

It's not. A list and a queue are different mental models, and the difference shows up in three places:

  1. The user's intent. A list user is browsing; a queue user is working through items. The list optimizes for finding; the queue optimizes for processing.
  2. The information density. A list shows everything; a queue shows the slice the operator is responsible for plus stats on the other slices they're not.
  3. The actions. A list has navigation (click to view); a queue has bulk row actions (assign owner, request evidence, change status, submit decision) without leaving the queue.

In Coreola, the queue lives at /collections/assessments/queue as a separate route with its own model hook (useQueueModel), separate from the list's useListModel. The queue introduces a queue_section filter — a persistent radio control with five slices:

needs_review | waiting_for_evidence | remediation_in_progress | blocked | overdue
Enter fullscreen mode Exit fullscreen mode

The selected slice maps to assessment query params and combines with the standard filters. A StatsLine shows counts for every slice (so the operator can see "11 needing review, 3 overdue") regardless of which one is currently filtered. The stats refresh independently of the table — useGetAssessmentsStatsQuery runs on its own cadence so scanning the worklist doesn't blow away the current scroll position.

Row actions are inline dialogs, not navigations: AssignOwnerDialogView, RequestEvidenceDialogView, ChangeStatusDialogView, SubmitDecisionDialogView. The operator stays in the queue while taking action. The queue becomes the operator's worksurface.

The list still exists, separately, for the browsing use case. Two routes, two model hooks, one shared table primitive.

Decision #5: composition over monolith for the details page

The details page is where most templates collapse into one big component. "Here's the assessment, here's all its data, here's every action available." 2,000 lines of JSX and a model hook with thirty mutations.

The shape that survives: a stack of small cards, each owning its own concern, each composed into a top-level model hook.

OverviewCardView    — title, status, owner, dates, metadata (toggleable edit mode)
DecisionCardView    — submit decision, change status
FindingsCardView    — sub-table of findings with row actions
EvidenceCardView    — sub-table of evidence requests with row actions
ActivityCardView    — timeline of AssessmentActivity events
Enter fullscreen mode Exit fullscreen mode

Each card pulls its own data slice from RTK Query. Each card has its own permission check ("does this user see this card at all?"). Each card has its own mutations through per-action sub-hooks (useCreateFinding, useEvidenceRequest, useAssignFindingOwner). The model hook composes them; it doesn't own them.

Dropping a card is deleting a file and removing one line of composition. Adding a card is the inverse. Domain variants — one workflow needs an extra "Approvers" card; another does not need Evidence — are configuration, not surgery.

This is the same decoupling that post #2 talked about for tables: orthogonal definitions that share only an id. The card stack is the same idea, scaled up one level: orthogonal sub-views that share only a parent entity.

The point

Five decisions, all of them invisible to a template that only models three CRUD screens. All of them obvious — almost trivial — once you've tried to build a real workflow and hit the wall the naive approach builds for you.

This is what "tested against a real workflow" means. Not "we have a working app with auth and a customer list." That's a starter template. A foundation is tested against something with multiple lifecycles, orthogonal dimensions, sub-resources, deep links, and actions that don't fit into a single CRUD screen — because that's the shape every operational product takes eventually.

The patterns from the previous three posts — server-driven tables, URL-scoped state, hidden-not-disabled permissions, decoupled definitions — were the inputs. The Assessments module is the output. Patterns are cheap to write down and expensive to verify. The verification is the foundation.

If you're starting an admin product and your workflow looks anything like this — items with statuses, sub-resources, decisions, and operator queues — the shape transfers. Rename the entity. Adjust the cards. Keep the conventions. The floor is already laid.


Coreola is a React admin foundation with the Assessments module built in as a reference implementation of these patterns. It can be adapted as a starting point for claims, tickets, applications, audits, reviews, or any workflow with statuses, sub-resources, decisions, and operator queues. Live demo at demo.coreola.com.

Top comments (0)