ClientStateVar: a component that mounts while the value is non-default never re-renders on the push back to the default
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 78/100
Research direction
Start in reflex/experimental/client_state.py at ClientStateVar.create and its global_ref path, then run the minimal reproduction from the issue. Verify that a component mounted while the shared value is non-default also updates when the value returns to its default, without requiring a remount.
Written by the indexing model from the issue text.
Description
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 isbusy. Mounted components holduseState === "busy".- A component mounts. Its
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.- It never re-reads the mirror, and keeps displaying
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 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
- Dominant language
- Python
- Stars
- 28.9k
- Forks
- 1.8k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 173
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from reflex-dev/reflex
-
Difficulty 1/5 1-3 hours Newbie friendliness 78/100
reflex-dev/reflex#7149 · 1 comment ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
reflex-dev/reflex#7148 · 1 comment ·
-
Difficulty 1/5 Under an hour Newbie friendliness 85/100
reflex-dev/reflex#7103 · 1 comment ·
-
support redis v8 Openfeature request
Difficulty 1/5 Under an hour Newbie friendliness 88/100
reflex-dev/reflex#6764 · 3 comments · 1 reaction ·
-
documentation upstream
Difficulty 2/5 1-3 hours Newbie friendliness 62/100
reflex-dev/reflex#6436 · 3 comments ·
All issues in reflex-dev/reflex
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
enhancement
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 74/100