Define the status contract before the component

Start with a small set of states:

const STATUS = {
  REQUESTED: "requested",
  UPLOADED: "uploaded",
  ACCEPTED: "accepted",
  NEEDS_REPLACEMENT: "needs_replacement",
};

Define their meaning:

  • requested: the client owns the next action;
  • uploaded: a submission exists and an authorized reviewer owns the next action;
  • accepted: the reviewer considers the request satisfied; and
  • needs_replacement: the submission was reviewed and the client owns a corrective action.

These are example states. Real workflows may need withdrawn, waived, expired, or closed. Add a state only when it changes ownership, allowed action, evidence, or communication. Do not use a status as a loose visual label.

The accompanying fixture allows:

requested -> uploaded
uploaded -> accepted
uploaded -> needs_replacement
needs_replacement -> uploaded

It rejects every other transition. The server must enforce the same or stricter rules.

Keep one source of truth

Store the document requests, not separate mutable counters:

const [documents, setDocuments] = useState(initialDocuments);
const summary = summarizeDocuments(documents);

React's state-structure guidance recommends avoiding redundant state and deriving information during rendering when possible. If acceptedCount and the document statuses are both mutable, they can disagree after an error, retry, or partial update.

The fixture derives:

  • total requests;
  • requested;
  • uploaded;
  • accepted; and
  • needs replacement.

The pure function is easy to test without rendering React.

Use stable request identifiers

Render each item with an identifier from the domain data:

<ul>
  {documents.map((document) => (
    <li key={document.id}>
      <h3>{document.label}</h3>
      <p>{STATUS_LABEL[document.status]}</p>
    </li>
  ))}
</ul>

React's list documentation explains that stable keys let React match items when rows are inserted, removed, or reordered. Do not use the array index or generate a random key during render. A missing-document list can change order as requests are added, satisfied, or prioritized; unstable identity can attach transient interface state to the wrong row.

The key is only a React reconciliation hint. Pass the request ID separately when an action needs it.

Ask the server to perform the transition

The illustrative component uses an injected callback:

async function requestTransition(requestId, nextStatus) {
  setMessage("Saving status…");
  try {
    await onTransition(requestId, nextStatus);
    setDocuments((current) =>
      transitionDocument(current, requestId, nextStatus),
    );
    setMessage(`Status updated to ${STATUS_LABEL[nextStatus]}.`);
  } catch {
    setMessage("The status could not be updated. Try again.");
  }
}

This example updates local state only after the callback succeeds. A production client may instead use server-returned canonical data, invalidate a query, or handle version conflicts. The important boundary is that the interface requests a transition; it does not declare itself authorized.

The OWASP Authorization Cheat Sheet recommends validating permissions on every request and warns against relying on client-side access control. Hiding the Accept button from a client improves the interface but does not prevent a crafted request.

The server should verify:

  • authenticated identity;
  • role and client or practice scope;
  • current server-side status;
  • allowed transition;
  • request version if concurrent edits matter;
  • required reason or evidence;
  • attributable audit data; and
  • an idempotency strategy where retry could duplicate an effect.

Announce asynchronous status changes

The example includes:

<p role="status" aria-live="polite">
  {message}
</p>

The W3C status-message guidance describes making important changes perceivable by assistive technology without requiring focus to move. “Saving,” “updated,” and “could not update” are useful only when they accurately reflect the request.

This code has not been tested with browsers and assistive technologies. Review the assembled component for:

  • keyboard order and visible focus;
  • button names that include enough context;
  • whether repeated updates are announced appropriately;
  • error recovery;
  • focus placement when new content requires immediate attention; and
  • color-independent status presentation.

An aria-live attribute is not a substitute for that review.

Keep file handling outside this example

The component does not upload or download files. A production file workflow needs a trusted backend that handles authorization, size and type policy, filename generation, storage, scanning where appropriate, retention, and safe retrieval.

The OWASP File Upload Cheat Sheet provides a fuller control inventory. Do not place cloud storage credentials in the React bundle or assume an accepted browser file type proves the file is safe.

The UI should represent backend outcomes such as “upload received” or “upload rejected.” It should not claim “accepted” before an authorized workflow action actually commits that state.

Run the state-model tests

The evidence fixture uses Node's built-in test runner and has no package dependencies:

node --test model.test.mjs

Its five cases demonstrate:

  1. summary counts are derived from document state;
  2. a valid transition returns new collection and document objects;
  3. a replacement can be uploaded after rejection;
  4. a request cannot skip from requested directly to accepted; and
  5. an unknown request identifier is rejected.

Those tests do not demonstrate React rendering, authentication, authorization, file storage, accessibility, network recovery, or concurrency. They preserve one small piece of business-state evidence that can be reused in a larger test plan.

Add failure states before adding polish

Before production use, decide how the assembled interface behaves when:

  • the initial list is loading;
  • there are no requests;
  • the user is authenticated but not authorized;
  • the server rejects a transition because the status changed elsewhere;
  • saving times out;
  • the user retries;
  • an upload succeeded but status refresh failed;
  • the list is stale; or
  • the service is unavailable.

Do not reuse business status for transport state. “Uploaded” should not mean “the browser started sending bytes,” and “accepted” should not mean “the button was clicked.”

Frequently asked questions

Why not use a boolean such as complete?

A boolean cannot distinguish waiting for the client, waiting for review, and requiring a replacement. Those states have different owners and actions.

Should the component update optimistically?

Only when the workflow can reconcile rejection, stale versions, retries, and conflicting edits without displaying false business state. Confirmed updates are simpler for a low-volume, high-consequence approval workflow.

Does disabling a button prevent an invalid transition?

No. It helps users avoid an unavailable action. The server must reject the transition based on authenticated identity, authorization, current state, and the workflow rule.