Read-Only Is a Contract, Not an Omission
Jun 27, 2026
Replacing one component with a newer one looks like a refactor. When that component renders on a public or shared route, it is something else: a safety contract. The lesson from one of these migrations is simple to state and easy to get wrong — read-only has to be built and enforced, not inferred from the absence of edit handlers.
The problem
A public, shareable view was still rendering through a legacy table component. The obvious task was to move it onto the newer, canonical component chain so it stopped drifting from the rest of the product.
But the public route had no strict read-only contract. The assumption holding the page together was that if you simply did not pass the mutation callbacks, nothing could change. That assumption was wrong in two directions. Some controls did disappear when their callbacks were missing — but others did not. Selection checkboxes still rendered. Child popovers, modals, a file-upload surface, and a background hook could each reach for an action on their own, without waiting to be handed one.
So the page looked read-only in the places the original author had thought about, and was quietly mutation-capable in the places they had not.
What actually happened
The fix had to become structural rather than shallow. Instead of leaving callbacks out and hoping, the team introduced an explicit read-only mode and propagated it through the whole component chain. In read-only mode the chain hid selection checkboxes and other mutation controls, suppressed side effects before they could fire, and the legacy render path was retired entirely so it could not come back.
Two decisions made this work.
The first was treating the migration as a read-only contract item, not a component swap. That reframing is what justified auditing every child surface — hooks, modals, popovers, the file-upload control, the server actions behind them — for ways to cause a change, rather than only checking the top-level edit handlers.
The second was an explicit product call: accept the new component's layout as the baseline. "Use the modern component" and "preserve the old shared view's exact appearance" were in conflict, and pretending they were compatible would have produced a plan nobody could build. Naming the trade-off out loud unblocked it.
The verification was honest about its own limits. The read-only behaviour was proven with targeted unit and contract tests, type checks, and a production build. But "no mutating request is issued" was demonstrated at the contract level, not yet with a full browser-and-network probe — and that gap was carried forward as an explicit residual risk rather than rounded up to "done."
The lesson
Omitting callbacks is not a read-only contract.
"No callback passed" silences the controls that happen to be wired to those callbacks. It does nothing about controls that render regardless, and nothing about child components that can originate their own actions. A page is read-only only when something in the system actively enforces that — a mode, a guard, a deliberate suppression of side effects — not when an edit path simply went unmentioned.
The corollary: a control disappearing in your manual test is not proof it cannot act. The dangerous surfaces are the ones you did not think to pass a handler to, because those are exactly the ones you also did not think to disable.
The broader principle
Public and shared routes deserve stricter treatment than ordinary read-only UI, because the audience is wider and less trusted and the blast radius of a stray mutation is larger.
Three habits follow from that.
Build a control-class table early. For every user-facing control on the route, decide explicitly: hide it, disable it, or show it as display-only. A control with no row in that table is a control nobody has reasoned about.
Audit for side effects, not just for mutation callbacks. Hooks, modals, popovers, file-upload components, and server actions are all surfaces that can cause a change without being handed an edit callback. Migrating a component can expose side effects that were dormant before. Look for them deliberately.
Match your proof to your claim. "The read-only prop is set" is a contract claim and a unit test can prove it. "No request is issued" is a network claim, and only a browser-and-network probe can prove it. If you cannot run the probe, do not silently upgrade the weaker evidence — carry the difference as a named residual risk.
How to apply it
When you next move a component that renders on a public or shared route:
- Treat it as a read-only contract item, not a component swap, before you estimate it.
- Decide, explicitly, whether the target is visual parity with the old view or the canonical behaviour of the new component. They often conflict.
- Write the control-class table: hide / disable / display-only for every control.
- Audit child surfaces — hooks, modals, popovers, file controls, server actions — for self-originated side effects, and suppress them before invocation rather than relying on missing handlers.
- Add regression coverage for every existing consumer of the component when you introduce a read-only mode, so the editor and template paths do not silently break.
- When the acceptance criterion is "no request is issued," prove it with a network probe, not just by asserting the prop is set.
Read-only is something you build and enforce. It is not something you get for free by leaving the edit path out.