A global ClientStateVar renders a shared mirror but re-renders off a per-component useState seeded from the literal default. For a component that mounts while the value is non-default the two drift permanently: it paints the live value once, then stops updating, because the push that would correct it is exactly the one React drops.
Minimal repro
import asyncio
import reflex as rx
from reflex.experimental import ClientStateVar
flag = ClientStateVar.create("flag", default="")
class State(rx.State):
mounted: bool = False
@rx.event(background=True)
async def go(self):
async with self:
self.mounted = False
yield flag.push("busy") # 1. push a non-default value
await asyncio.sleep(1)
async with self:
self.mounted = True # 2. mount a subtree reading the same var
await asyncio.sleep(1)
yield flag.push("") # 3. push back to the default
def index() -> rx.Component:
return rx.el.div(
rx.el.button("go", on_click=State.go, id="go"),
rx.el.div(flag.value, id="always"),
rx.cond(State.mounted, rx.el.div(flag.value, id="late")),
)
app = rx.App()
app.add_page(index, route="/")
Click go and watch the two divs. Both read the same var.
| t (ms) |
#always |
#late |
| 250 |
busy |
not mounted |
| 1250 |
busy |
busy |
| 2250 |
`` (empty) |
busy |
| 8000 |
`` (empty) |
busy |
#late never recovers. It is still busy indefinitely, and only a remount clears it.
Mechanism
ClientStateVar.create(..., global_ref=True) emits this per mounting component (reflex/experimental/client_state.py, create):
const id_N = useId()
const [x, setX] = useState("") // the literal default
refs['_client_state_setX'] = (v) => { /* fan out to every registered setX, then */
refs['_client_state_x'] = v }
refs['_client_state_x'] ??= x // shared mirror, seeded once
refs['_client_state_dict_x'][id_N] = refs['_client_state_x'] // render value: the MIRROR
refs['_client_state_dict_setX'][id_N] = setX
.value compiles to refs['_client_state_dict_x'][id_N], so every component renders the mirror. The per-component useState is never read for display. Its only job is to trigger the re-render that re-reads the mirror.
That holds while a component's useState tracks the mirror, and mounting is where it stops:
push("busy"). Mirror is busy. Mounted components hold useState === "busy".
- A component mounts. Its
useState initializes to the default "", not to busy. Its first render reads the mirror, so it displays busy correctly. It is now one push behind: mirror busy, local state "".
push("") calls setX("") on that component with the value it already holds. React bails out of the re-render.
- It never re-reads the mirror, and keeps displaying
busy until it unmounts.
Step 3 is the bug. Every other component re-renders normally, so the stuck one sits next to correct ones reading the same var, which is what makes it confusing in a real app.
Why it is easy to misdiagnose
A remount fixes it, so it presents as a stale render or a reconciliation problem. It is neither. The instance is simply recreated with mirror and useState back in agreement.
It is also intermittent in real code, because it depends on a mount landing inside the window. We hit it in an app where a decorator publishes in-flight flags through four global ClientStateVars to drive spinners and disabled states. A popover swapped an rx.cond branch mid-operation and the new branch's button stayed on its busy label forever, while a sibling in the always-mounted wrapper rendered the same var correctly.
Suggested fix
Either half alone closes it:
- Seed from the mirror:
useState(() => refs['_client_state_x'] ?? default), so a late mount starts in agreement.
- Render from local state instead of the mirror, so the displayed value and the re-render trigger are the same value.
The first is the smaller change and keeps the mirror as the source of truth.
Workaround
If you control the value space, pick a default the backend never pushes, so no push can match a stranded component's held value and React cannot drop it. Changing default="" to default="IDLE" in the repro above makes #late clear correctly at t=2250 along with #always.
Not general: a ClientStateVar with a boolean default has nowhere to put a sentinel.
Environment
- reflex 0.9.6.post2
- React 19.2.6
- Python 3.14
A global
ClientStateVarrenders a shared mirror but re-renders off a per-componentuseStateseeded from the literal default. For a component that mounts while the value is non-default the two drift permanently: it paints the live value once, then stops updating, because the push that would correct it is exactly the one React drops.Minimal repro
Click
goand watch the two divs. Both read the same var.#always#latebusybusybusybusybusy#latenever recovers. It is stillbusyindefinitely, and only a remount clears it.Mechanism
ClientStateVar.create(..., global_ref=True)emits this per mounting component (reflex/experimental/client_state.py,create):.valuecompiles torefs['_client_state_dict_x'][id_N], so every component renders the mirror. The per-componentuseStateis never read for display. Its only job is to trigger the re-render that re-reads the mirror.That holds while a component's
useStatetracks the mirror, and mounting is where it stops:push("busy"). Mirror isbusy. Mounted components holduseState === "busy".useStateinitializes to the default"", not tobusy. Its first render reads the mirror, so it displaysbusycorrectly. It is now one push behind: mirrorbusy, local state"".push("")callssetX("")on that component with the value it already holds. React bails out of the re-render.busyuntil it unmounts.Step 3 is the bug. Every other component re-renders normally, so the stuck one sits next to correct ones reading the same var, which is what makes it confusing in a real app.
Why it is easy to misdiagnose
A remount fixes it, so it presents as a stale render or a reconciliation problem. It is neither. The instance is simply recreated with mirror and
useStateback in agreement.It is also intermittent in real code, because it depends on a mount landing inside the window. We hit it in an app where a decorator publishes in-flight flags through four global
ClientStateVars to drive spinners and disabled states. A popover swapped anrx.condbranch mid-operation and the new branch's button stayed on its busy label forever, while a sibling in the always-mounted wrapper rendered the same var correctly.Suggested fix
Either half alone closes it:
useState(() => refs['_client_state_x'] ?? default), so a late mount starts in agreement.The first is the smaller change and keeps the mirror as the source of truth.
Workaround
If you control the value space, pick a default the backend never pushes, so no push can match a stranded component's held value and React cannot drop it. Changing
default=""todefault="IDLE"in the repro above makes#lateclear correctly at t=2250 along with#always.Not general: a
ClientStateVarwith a boolean default has nowhere to put a sentinel.Environment