Skip to content

Substate field shadowing a parent's base var is silently dropped (wrong value, non-reactive class access) #7074

Description

@masenf

Describe the bug

When a substate declares a field whose name matches a base var it inherits, the declaration is silently discarded. No warning, no error. The var keeps the parent's value and type, writes land on the parent, and class-level access returns the raw Python default instead of a Var — so using it in a component renders a static value rather than a reactive binding.

That substates can't redefine parent vars is a known limitation (#3820 asks for the capability). This issue is narrower and separate: whatever the answer there, the restriction is currently enforced silently and produces wrong values instead of raising.

Mechanism

get_skip_vars() includes set(cls.inherited_vars) (reflex/state.py:1114-1130), and base_vars filters on it (reflex/state.py:691-695):

cls.base_vars = {
    name: get_var_for_field(cls, name, f)
    for name, f in cls.get_fields().items()
    if name not in cls.get_skip_vars() and f.is_var and not name.startswith("_")
}

So the field stays in cls.get_fields() but never becomes a base var. _init_var therefore never runs for it, leaving pydantic's raw default as the class attribute; and __getattribute__ (reflex/state.py:1531, the name in inherited_vars branch) delegates reads and writes to the parent.

Contrast with computed vars, where the same collision does raise — _check_overridden_basevars raises ComputedVarShadowsBaseVarsError. Base vars have no equivalent guard.

To Reproduce

import reflex as rx
from reflex.state import State


class Parent(State):
    x: int = 1


class Child(Parent):
    x: str = "ninety-nine"   # silently ignored


root = State(_reflex_internal_init=True)
child = root.get_substate(Child.get_full_name().split(".")[1:])
parent = root.get_substate(Parent.get_full_name().split(".")[1:])

print(child.x)              # 1        (int!) — not "ninety-nine", not even a str
child.x = 42
print(parent.x)             # 42       — the write landed on the parent
print(child.dirty_vars)     # set()
print(parent.dirty_vars)    # {'x'}
print(repr(Child.x))        # 'ninety-nine' — a plain str, not a Var

Also observable without the type change (x: int = 99 on the child): Child.base_vars omits x, child.x is 1, and child.dict() has no x key.

Expected behavior

Either honor the redefinition (#3820), or reject it loudly — raise at class-creation time the way a shadowing computed var already does. The failure mode that shouldn't exist is the current one: the declared annotation and default are accepted by the type checker and by get_fields(), then discarded at runtime with no diagnostic.

The Child.x case is the most damaging in practice, because it fails without any exception: rx.text(Child.x) compiles a literal string into the page instead of a reactive var, so the value simply never updates.

Specifics

  • Python Version: 3.14.7
  • Reflex Version: 0.9.10.post33.dev0+065ddfef (main @ 065ddfe)
  • OS: Linux

Additional context

Long-standing, not a recent regression — the set(cls.inherited_vars) filter predates the current state.py layout. Surfaced while reviewing #7068, where the same path silently swallows a field named router; that PR doesn't change this behavior in either direction.

Related: #3820 (feature request for the capability), #1783 and #1368 (earlier, closed).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions