Skip to content

perf: re-render state readers only when a field they read changes - #7583

Open
FarhanAliRaza wants to merge 2 commits into
reflex-dev:mainfrom
FarhanAliRaza:farhan/field-level-state-subscriptions
Open

FarhanAliRaza wants to merge 2 commits into
reflex-dev:mainfrom
FarhanAliRaza:farhan/field-level-state-subscriptions

Conversation

@FarhanAliRaza

@FarhanAliRaza FarhanAliRaza commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Follow-up to #7441, which put all substates in one shared store.

Problem

A component that reads one field of a state re-renders when any field of that state changes. A state with 20 fields shown in 20 places re-renders all 20 places when one field changes.

Change

  • VarData.from_state(state, field) emits useStateContext("S", ["field_rx_state_"]). Without a field, the hook has no list, as before.
  • _sort_hooks merges the hooks of a component into one useStateContext call per state, with the union of the fields. A hook without a list reads the whole substate and wins over the field lists.
  • useStateContext(name, fields) keeps the previous snapshot while the listed fields are unchanged, so useSyncExternalStore skips the render. The store does not change.
  • Var._var_set_state adds the whole-substate hook, because its JS expression can read any key.

Event handlers in the component read the same object. Their arguments are vars of the component, so their fields are in the list and their closures stay current.

Measured

Own small shop app (8 states, /big page with a 20-field state), prod build, React commit hook counting the function components that render per section. main = 5ae5dcd528.

Action main this PR
change 1 of 20 shown fields 20 renders 1
change last_echo (the echo button does not read it) echo buttons 3 + output 1 output 1
counter inc counter section 2 1
no-op event 0 0

Final values are the same on both. No page errors.

Field coverage check on compiled output: for each compiled component, every stateVar.field read is in that component's field list.

  • shop app: 34 state hooks, all with a list
  • examples/playground: 216 state hooks, all with a list, no missing field

Hot reload in dev: adding a field, a text and an event to a mounted component shows the new default value, and both the new and the old fields update on click.

Known limit

A hand-written Var that reads a state field in raw JS, with no VarData.from_state for that field, gets no subscription of its own. Before this PR it was correct only when another var of the same state was in the same component. With this PR, that component re-renders only for the fields in its list, so the hand-written read can show a stale value.

View guided diff

Each state var hook now names its field, the compiler merges the hooks of a
component into one useStateContext call per state with the union of fields,
and useStateContext keeps the previous snapshot while those fields are
unchanged. A hook without a field list still reads the whole substate.
@FarhanAliRaza
FarhanAliRaza requested a review from a team as a code owner October 16, 2026 17:29
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-16T17:38:25.681604Z 4e5cd02 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@FarhanAliRaza FarhanAliRaza added the perf Performance-improving changes label Oct 16, 2026

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 9 files

Tip: Get a Slack DM when cubic finishes reviewing your PRs. Connect Slack

View guided diff | Re-trigger cubic

Comment thread packages/reflex-base/news/+field-state-subscriptions.performance.md Outdated
@greptile-apps

greptile-apps Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 5/5

[High impact] The latest changes appear safe to merge; the previous build-breaking issue is fixed.

Summary

Components subscribe to the state fields they read, so unrelated field changes can skip renders.

  • Components re-render only when a state field they read changes.

Reviews (2) · Last reviewed commit: "fix: give _var_set_state only a whole-su..." · Reviewed by Greptile

Comment thread packages/reflex-base/src/reflex_base/compiler/templates.py
Its expression is not a field name, so a field hook built from it could hold
a ']' the hook merge cannot parse, leaving two declarations of the state.
@codspeed

codspeed Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Merging this PR will not alter performance

⚠️ 18 benchmarks spent significant time in system calls

System calls cannot be consistently instrumented, so they are not included in the measure, which understates the real cost. Please switch to the Walltime instrument to accurately measure system calls.

Measurement and system calls

✅ 148 untouched benchmarks


Comparing FarhanAliRaza:farhan/field-level-state-subscriptions (dae7365) with main (5ae5dcd)

Open in CodSpeed

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4e5cd02fb9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/reflex-base/src/reflex_base/compiler/templates.py
@FarhanAliRaza

Copy link
Copy Markdown
Contributor Author

A/B on real apps: main 5ae5dcd528 vs this PR dae7365a8c

Setup:

  • Apps: examples/playground (the benchmark app: many pages, a sqlite table with 2000 rows, forms) and a small shop app of my own.
  • Builds: prod builds of both sides from the same worktree. For main, main's versions of the 3 changed source files are checked out.
  • Counting: a React DevTools commit hook counts every function component that renders after each action.

Playground renders

Action main PR
events: sync +1 / args +5 / async +10 15 each 3 each
events: generator (progress updates) 92 51
events: clear 15 12
data: filter "lamp" 104 17
data: next page 370 282
data: reset 374 287
counter +1 3 3
forms submit 236 236

The counter and forms pages do not change, because their states have only one or two shown fields.

Shop app renders

Action main PR
change 1 of 20 shown fields 20 1
change a field the echo buttons send but do not show 4 3
change a field only the output shows 4 1

On both apps, every final value on the page is the same as on main, and there are no page errors.

React Doctor 0.9.18 (offline, on the compiled .web sources)

App main PR different findings
shop 2 2 none
playground 234 234 none

There is no finding on context-registry.js. The 234 playground findings are already on main: 230 exhaustive-deps on generated useCallback dependency lists that include addEvents and ReflexEvent without need, and 4 prefer-dynamic-import.

masenf commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator

Possible hook-ordering issue in _merge_state_hooks: it looks like merging two field subscriptions can lose an INTERNAL position requirement and put the state declaration after a hook that reads it.

Compared main 5ae5dcd5281497b395bd9ec6b7b6ca229e293f39 with this head dae7365a8cfd8165ddfa63b5d633a99d7b613d30. The default compiler pipeline produced a component that rendered 2 on main, but threw ReferenceError: Cannot access 'reflex___state____state____main_____s' before initialization on this head in React 19.2.0 test-renderer.

Here is the compiler reproducer. Save as repro_order.py in a checkout with its Python dependencies installed, then run uv run python repro_order.py > generated.js on each revision (separate worktrees are convenient):

from dataclasses import dataclass
from typing import Callable

import reflex as rx
from reflex_base.constants.compiler import MemoizationMode
from reflex_base.plugins import CompileContext, CompilerHooks
from reflex_base.registry import RegistrationContext
from reflex.compiler.plugins import default_page_plugins
from reflex.compiler.compiler import compile_memo_components

@dataclass
class Page:
    route: str
    component: Callable
    title: str = ""
    description: str = ""
    image: str = ""
    meta: tuple = ()
    on_load: object = None
    _source_module: str | None = None

with RegistrationContext():
    class S(rx.State):
        a: int = 1
        b: int = 2

    class ParentHook(rx.el.Div):
        _memoization_mode = MemoizationMode(recursive=False)

        def add_hooks(self):
            return [rx.Var(f"const parentRead = {S.a}", _var_type=int)]

    def page():
        local = rx._x.client_state(
            "childValue", default=S.b, global_ref=False
        )
        return ParentHook.create(rx.el.div(local, local.value))

    ctx = CompileContext(
        pages=[Page(route="/p", component=page)],
        hooks=CompilerHooks(plugins=default_page_plugins()),
    )
    with ctx:
        ctx.compile()
    modules, _ = compile_memo_components(
        memos=tuple(ctx.auto_memo_components.values())
    )
    for _, source in modules:
        if "parentRead" in source:
            print(source)

On the PR, the relevant output is ordered like this (state identifier shortened):

const [childValue, setChildvalue] = useState(s.b_rx_state_)
const s = useStateContext("...", ["a_rx_state_", "b_rx_state_"])

Main declares s first. The parent's add_hooks dependency has the default/PRE_TRIGGER position; the child's state dependency requires INTERNAL. These now have different field-specific hook keys, and the merge keeps only the first hook's metadata. Preserving the earliest required position across the merged hooks might fix this. A mixed-position regression test would help; the current merge tests use INTERNAL for every input.

The S.a interpolation carries generated Var metadata. This uses the experimental client_state API, which accepts a Var default, and a supported nonrecursive memo boundary.

Validation scope: full default Python compiler pipeline plus the generated modules in real React/react-test-renderer 19.2.0, with imports remapped for standalone execution (Emotion jsx mapped to React.createElement). The renderer mounted the generated component under the actual StateStoreContext.Provider, with the actual store seeded with a_rx_state_: 1, b_rx_state_: 2. No full browser/Vite run. Existing diagnostic Python dependencies were reused; fresh-environment dependency installation was not tested.

This branch has not been deployed

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

Labels

perf Performance-improving changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants