ClientStateVar: a component that mounts while the value is non-default never re-renders on the push back to the default

Open Beginner friendly
#6,823 1 comment 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Assessment

Difficulty
2/5
Estimated time
1-3 hours
Newbie friendliness
78/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Quiet
Tech stack
python, react
Domain
frontend

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:

  1. push("busy"). Mirror is busy. Mounted components hold useState === "busy".
  2. 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 "".
  3. push("") calls setX("") on that component with the value it already holds. React bails out of the re-render.
  4. 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
Dominant language
Python
Stars
28.9k
Forks
1.8k
Avg merge
1d 20h
Merged PRs (30d)
173

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from reflex-dev/reflex

All issues in reflex-dev/reflex

Similar issues

More Python issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.