Actions do not remove the need for product state
React 19's release-candidate Actions improved mutation ergonomics, while V0M3 kept authority, receipts, conflicts, and recovery in its document workflow.
React 19's release candidate made an acceptance form pleasantly small.
The form could call an Action, expose pending state through form context, update state from the result, and use optimistic UI without a custom mutation hook. V0M3's client code became easier to read.
The document still needed to decide whether the author could accept the proposal, whether the target revision remained current, whether the patch had already committed after a lost response, and what to do when external generation outcome was unknown.
Actions improved the interface between a form and server work. They did not become V0M3's product state machine.
I treated the release candidate as a bounded experiment
The April 2024 release was a release candidate, not the stable React 19 release that would arrive later. I tested the new primitives on one internal V0M3 acceptance route and preserved the existing command endpoint and document protocol.
The experiment used:
- A form Action for submitting selected proposal operations.
useActionStatefor the latest command result.useFormStatusinside the submit control for pending UI.useOptimisticfor a temporary review-queue presentation.
It did not migrate every mutation or treat release-candidate APIs as timeless infrastructure. The article records what I evaluated then.
The form's pending state began when the Action invocation started and ended when its result settled. It was useful for preventing accidental repeated clicks and communicating that a request was in flight.
It did not prove:
- The document patch had committed.
- A provider request had completed.
- The client had received the only possible result.
- Another tab had not changed the target.
- An external export had been reversed.
V0M3 displayed pending as “Submitting acceptance,” then rendered durable server state from the command receipt. A page reload recovered from the receipt, not from an old hook boolean.
Renderer state remained narrower than product state.
The Action called a domain command
The server function did not update document text directly from form fields. It parsed a typed request and called the document service:
acceptProposal({
requestId,
documentId,
expectedRevision,
proposalRevision,
selectedOperations,
patchDigest,
reviewReceipt,
})The service revalidated current author authority, target and patch digests, evidence policy, operation graph, and request identity. It committed one document revision or returned a typed conflict or refusal.
React provided the invocation path. V0M3 owned the decision.
Form data was untrusted input
Hidden inputs contained IDs and digests visible to the browser. A modified client could submit another proposal, add an unreviewed operation, or claim a different expected revision.
The Action authenticated current identity and loaded server records. It never accepted client-supplied review state as proof. Selected operation IDs had to belong to the exact proposal revision and allowed document. Patch digest was recomputed.
Progressive enhancement made forms work before client JavaScript and did not make their fields authoritative.
The server boundary still enforced policy.
useActionState held a view of the result
The hook made it convenient to render validation errors, conflicts, and the successful receipt beside the form. Its state belonged to that mounted interaction.
The durable command and document revision lived on the server. Navigating away, opening another tab, or remounting the component did not erase or reverse them. On load, the page queried current document, proposal, and acceptance receipts and reconstructed the view.
I avoided storing the entire workflow only in the Action's returned state. The hook presented one transition outcome; it did not own workflow history.
V0M3 could optimistically mark selected operations as “submitting” and collapse their review controls. It could preview the expected document patch because the author had already reviewed it.
It did not show the canonical document revision as accepted before the server committed. A conflict might reveal a newer target, expired evidence, or invalid approval. Presenting success early would require a visible rollback and could mislead export or navigation.
I used optimism for reversible presentation and retained an explicit Provisional label. The server receipt transformed it into accepted state.
Optimistic UI did not become optimistic authority.
Validation errors differed from conflicts
A missing selection, malformed field, or unsupported operation could return field-level validation. A changed document revision was a concurrency conflict. Lost permission was an authorization result. Unknown prior request outcome required receipt lookup.
The Action result used a discriminated shape:
invalid
conflict
forbidden
accepted
already-accepted
temporarily-unavailable
outcome-unknownThe UI offered actions appropriate to each. A generic error string and Retry button would erase the recovery model.
Mutation ergonomics worked best after domain outcomes were already named.
A browser could submit, lose the response, and submit again. V0M3 generated or recovered a stable request ID tied to proposal, patch, and expected revision.
The command service returned the existing acceptance receipt for an identical request. Reusing the ID with different selected operations failed. The submit control's pending state helped prevent fast duplicates and was not the guarantee.
Idempotency lived in durable command data so it survived tabs, reloads, retries, and different client implementations.
The form could be progressively enhanced because correctness did not depend on one component instance.
Native form semantics provided a clear submit path. useFormStatus allowed the button to communicate pending state without prop-drilling. Validation summary linked to affected controls. Focus moved to the accepted revision heading, first invalid field, or conflict explanation according to result.
The button label changed to “Accepting 3 changes” and remained stable enough for assistive technology. An indeterminate animation was not the only feedback.
The new primitives reduced custom client plumbing and left responsibility for understandable states intact.
Several buttons needed separate intent
The review form offered Accept selected, Reject selected, and Save proposal edits. One broad Action branching on button text would be fragile and difficult to authorize.
Each submitter carried a typed command intent with its own server handler or explicit discriminator. Pending UI named the action being performed. Permissions and receipts differed: rejecting a proposal did not create a document revision; accepting did.
Form ergonomics did not justify one mutation endpoint that guessed what the author meant.
The editor contained proposal controls, evidence search, and document metadata. I kept forms structurally separate and used explicit button types so a citation interaction did not submit acceptance.
Keyboard Enter behavior was tested in text fields, operation checkboxes, dialogs, and evidence search. A form Action made submission convenient; the interface still had to prevent a broad consequential action from becoming an accidental default.
The accepted command always summarized exact operations before commit.
Streaming UI and Action state met at durable records
A proposal could still be generating operations while the author reviewed completed ones. Acceptance required a closed proposal revision or an explicit partial revision whose operation plan was final.
The form did not submit against a live token stream. It named immutable proposal and selected operation IDs. Claim streaming and form Action therefore met through durable state, not shared component flags.
This made concurrent rendering and provider transport irrelevant to patch correctness.
Cache invalidation followed the receipt
After acceptance, the source document, proposal queue, relationship view, and search ingestion status could each need refresh. I avoided broad “refresh everything” behavior that hid which state was authoritative.
The command receipt named the new document revision. The route rendered it directly. Current-document caches invalidated through the document's tag or key. Search remained a projection and showed its own corpus/index lag until ingestion caught up.
A successful Action did not mean every derived view was instantly consistent.
Failure after commit remained safe
The server could commit the document revision and then fail while constructing the response or invalidating a cache. The client would see an error despite success.
Stable request identity let a retry retrieve the receipt. Rendering current document state confirmed the new revision. Cache repair ran from durable outbox or reconciliation rather than asking the author to accept again.
The Action handler kept the database transaction and subsequent effects distinct.
Convenient server invocation did not create a distributed transaction around the response.
A route error boundary could reset failed rendering. It could not safely resubmit an acceptance whose outcome was uncertain.
Mutation results stayed in the form and durable workflow UI. Unexpected rendering failure after acceptance recovered by reloading the receipt. The error boundary never inferred that server state had rolled back because the component tree reset.
React recovery scope and domain recovery scope remained separate.
Two tabs could review the same proposal. Tab A accepted operations into document revision 18. Tab B still showed revision 17 and submitted another selection.
The service returned conflict with current revision and acceptance receipt. Tab B could compare, rebase remaining operations, or close. It did not overwrite A or treat the Action state in its own tab as current authority.
The test demonstrated why a server function call is not equivalent to local state update.
The old endpoint remained useful
The command service was callable from the established HTTP path, the Action adapter, tests, and potential non-React clients. I did not duplicate policy inside the Action.
This preserved portability and made the experiment reversible. If release-candidate integration changed, the document workflow remained intact.
Framework primitives improved one adapter at the product edge. They did not become the domain API.
The acceptance path worked as an ordinary form where the framework supported progressive enhancement. The server returned a navigable success, conflict, or validation page. Client code enhanced pending and optimistic presentation.
I verified both paths produced the same command shape and receipts. A no-JavaScript submission could not bypass a review requirement hidden only in the client.
The baseline path was slower to feel and equally correct.
Release-candidate usage stayed documented
The experiment recorded React release, APIs used, framework integration, known caveats, fallback path, evaluation fixtures, and removal or upgrade condition. I did not describe later stable behavior as though it were guaranteed in April.
V0M3 could retain the experiment, change it before stable release, or return to the endpoint form without migrating document records.
Historical accuracy mattered in the code decision and the article.
Actions made the right code smaller
The final form needed less custom pending-state wiring and less client mutation scaffolding. Result rendering became local and understandable. Progressive submission improved.
The underlying product state remained deliberately rich:
proposal-ready → submitting-acceptance
→ accepted | invalid | conflict | forbidden
| already-accepted | unavailable | outcome-unknownReact did not need to own those meanings to make their interface cleaner.
The best result was not choosing between framework convenience and domain rigor. A clear server Action adapter made it easier to show the actual command outcomes. A strong command model made the Action small.
Pending, optimistic, and returned state served the immediate interaction. Stable intent, revision checks, policy, receipts, and recovery served the durable document.
Actions do not remove the need for product state. They remove some ceremony between product state and the person trying to change it.
The durable boundary stayed unchanged.
The same acceptance fixtures ran through the established endpoint and the Action adapter: valid patch, duplicate request, changed document, expired review, denied author, commit followed by lost response, and stale cache. Both produced equivalent command receipts and current document revisions.
The Action path could improve pending feedback and progressive submission without inventing different business behavior. A mismatch meant the adapter had absorbed policy it did not own.
This contract test made adoption reversible and prevented the newer interface from receiving privileged semantics.
Stable release would trigger a fresh review
The release-candidate experiment had a revisit condition rather than an assumption that its exact integration would ship unchanged. Stable React documentation, framework support, accessibility behavior, and build output would be checked again.
Any API or behavior change could update the adapter and tests while proposal and document records stayed untouched. Historical notes would retain the RC context.
The boundary between framework and product state made waiting for that evidence inexpensive.
A proposal could contain many operations and large text. Sending the entire generated patch through hidden form fields would duplicate sensitive content in the browser and allow oversized submissions.
The form sent stable proposal and operation IDs plus the exact review selection and digest. The server loaded canonical proposal content under current authorization. Request parsers capped field count, ID count, and text fields used for reviewer notes.
The browser described the choice; it did not resend the source of truth.
Review receipts were not booleans
A hidden input called reviewed=true would let any modified client claim completion. Review receipts were server records binding reviewer, proposal revision, operation selection, evidence checks, policy revision, and time.
The Action referenced the receipt. Acceptance validated that it belonged to the current requester or an allowed reviewer role, had not expired, and matched the patch digest. Editing the proposal or selection invalidated it.
This made review state durable and prevented form convenience from turning it into a client assertion.
Concurrent rendering could prepare and discard optimistic UI. The temporary presentation therefore never triggered analytics, export, or follow-up effects merely by rendering.
Only the accepted command receipt emitted a durable document-revision fact. The optimistic reducer was pure and reversible from server result. Remounting the form reconstructed it from current proposal and command state.
The React scheduling model stayed safely inside presentation because consequential work lived elsewhere.
Validation returned data, not exception prose
Expected invalid, conflict, and forbidden outcomes used typed results. Unexpected exceptions went through operational error handling with a safe request identity.
The client did not parse error-message strings to decide whether Retry or Rebase should appear. Field errors carried paths and codes; conflicts carried current revision and related receipts; temporary unavailability carried a resume path.
Typed product outcomes improved both accessibility and incident diagnosis.
Redirects happened after durable success
Navigating to the new document revision was useful after acceptance. Redirecting before the response existed could obscure a conflict or make an unknown outcome look like success.
The Action returned or performed navigation using the committed revision identity according to the integration path. Reloading that URL reconstructed accepted state from the document service. If response delivery failed, receipt lookup recovered the destination.
Navigation followed commitment; it did not stand in for it.
Acceptance was local document work and generation could involve an external provider. One generic mutation limit would let expensive generation starve ordinary editing or make document acceptance depend on provider quota.
The Action path used per-author command limits and concurrency appropriate to document updates. Provider attempts used task and adapter budgets. A rate-limited generation did not disable manual acceptance of an already reviewed proposal.
Separating capabilities preserved the document when AI infrastructure was constrained.
Security review included closure capture
A server Action could close over module values. I checked that no client-provided or serialized reference exposed credentials, unrestricted repositories, or broad document objects. The handler constructed bounded dependencies server-side and accepted only validated form input.
Build and response inspection searched for sentinel secrets. Logs avoided dumping form payloads or source text. The Action's convenience did not weaken the same server/client boundary established in K81R.
State names survived a future adapter
If V0M3 later invoked acceptance through an API client, command line, or another framework, the domain result types and receipts remained the same. Only pending presentation differed.
This was the portability test: remove the Action adapter and the document state machine still described every accepted, conflicted, and unknown request.
Framework ergonomics had helped without becoming the vocabulary of the document. That distinction endured.