Skip to content

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

Description

@adhami3310

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions