From 74730305f3e0f5f39ad73328d3758f8a7abf8eb8 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Wed, 26 Aug 2026 07:12:33 +0000 Subject: [PATCH 1/3] Create separate context providing components for each state Instead of combining all states and dispatchers into the same component (which is the re-render domain), separate each state into its own provider component and combine those into StateProvider. Since StateProvider doesn't directly depend on each state's context, it doesn't have to re-render just because some substate changed. - `StateProvider` keeps its exported name -- it is the app-wrap tag in `reflex_base.components.state_context` -- and is now a pure composer; the per-substate reducer lives in a new `SubstateProvider`. - `DispatchProvider` publishes the plain registry object (`ref.current`) rather than the ref wrapper, so reads and writes agree. - Dispatcher registration runs in a layout effect. `EventLoopProvider` mounts below the state providers, so its passive effect connects the socket before any ancestor passive effect would have registered a dispatcher, and `state.js` treats a delta for an unregistered substate as a fatal state mismatch. Falls back to `useEffect` on the server, where `useLayoutEffect` warns. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015aV7hXqYysQQCFAu7dKxNX --- packages/reflex-base/news/6181.performance.md | 1 + .../src/reflex_base/compiler/templates.py | 75 +++++++++++++------ tests/units/compiler/test_compiler.py | 62 +++++++++++++++ 3 files changed, 115 insertions(+), 23 deletions(-) create mode 100644 packages/reflex-base/news/6181.performance.md diff --git a/packages/reflex-base/news/6181.performance.md b/packages/reflex-base/news/6181.performance.md new file mode 100644 index 00000000000..b2caf5c376e --- /dev/null +++ b/packages/reflex-base/news/6181.performance.md @@ -0,0 +1 @@ +Give each substate its own React context provider in the generated `utils/context.js`. A state delta now re-renders only the provider for the substate it touched, instead of recreating every `StateContexts` element under a single combined provider. Dispatchers register into a stable shared registry during the layout phase, so they are in place before the event loop connects the websocket. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index d8239387903..bed53fbe368 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -347,18 +347,8 @@ def context_template( """ ) - state_reducer_str = "\n".join( - rf'const [{format_state_name(state_name)}, dispatch_{format_state_name(state_name)}] = useReducer(applyDelta, initialState["{state_name}"])' - for state_name in initial_state - ) - create_state_contexts_str = "\n".join( - rf"createElement(StateContexts.{format_state_name(state_name)},{{value: {format_state_name(state_name)}}}," - for state_name in initial_state - ) - - dispatchers_str = "\n".join( - f'"{state_name}": dispatch_{format_state_name(state_name)},' + rf"createElement(SubstateProvider, {{substateName: '{state_name}', contextName: '{format_state_name(state_name)}'}}," for state_name in initial_state ) @@ -390,7 +380,7 @@ def context_template( else "" ) - return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ createContext, useContext, useMemo, useReducer, useState, createElement, useEffect }} from "react" + return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ createContext, useContext, useMemo, useReducer, useRef, useState, createElement, useEffect, useLayoutEffect }} from "react" import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state" import {{ jsx }} from "@emotion/react"; {disable_owner_stacks_str} @@ -478,19 +468,58 @@ def context_template( ); }} -export function StateProvider({{ children }}) {{ - {state_reducer_str} - const dispatchers = useMemo(() => {{ - return {{ - {dispatchers_str} - }} - }}, []) +// ``useLayoutEffect`` warns when rendered on the server, where no effect runs +// at all, so fall back to ``useEffect`` there. +const useIsomorphicLayoutEffect = + typeof document !== "undefined" ? useLayoutEffect : useEffect; + +// Holds the mutable substate -> dispatch registry that ``SubstateProvider`` +// writes into and ``EventLoopProvider`` reads. The registry object identity is +// stable for the lifetime of the tree, so neither adding a dispatcher nor +// updating a substate re-renders the consumers of ``DispatchContext``. +const DispatchProvider = ({{ children }}) => {{ + const dispatchers = useRef({{}}); + return useMemo( + () => + createElement(DispatchContext, {{ value: dispatchers.current }}, children), + [children, dispatchers], + ); +}}; - return ( - {create_state_contexts_str} - createElement(DispatchContext, {{value: dispatchers}}, children) +// One provider per substate: each owns its own reducer, so a delta for one +// substate only re-renders that substate's context consumers instead of every +// consumer in the tree. +const SubstateProvider = ({{ children, substateName, contextName }}) => {{ + const dispatchers = useContext(DispatchContext); + const [state, dispatchSubstate] = useReducer( + applyDelta, + initialState[substateName], + ); + // A layout effect, not a passive one: layout effects for the whole commit + // run before any passive effect, so every dispatcher is registered before + // ``EventLoopProvider`` (mounted below this provider) connects the socket. + // A delta naming an unregistered substate is a fatal state mismatch. + useIsomorphicLayoutEffect(() => {{ + dispatchers[substateName] = dispatchSubstate; + return () => {{ + delete dispatchers[substateName]; + }}; + }}, [dispatchers, dispatchSubstate, substateName]); + return useMemo( + () => createElement(StateContexts[contextName], {{ value: state }}, children), + [children, state, contextName], + ); +}}; + +export function StateProvider({{ children }}) {{ + return useMemo( + () => ( + createElement(DispatchProvider, {{}}, + {create_state_contexts_str}children {")" * len(initial_state)} - ) + )), + [children], + ); }}""" diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index d6994a100f8..a4122dc7f29 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1425,3 +1425,65 @@ def test_context_template_owner_stack_pin(disable_owner_stacks: bool): assert "REFLEX_REACT_OWNER_STACKS" in rendered # The trade-off must be stated where a reader of the output will see it. assert "captureOwnerStack" in rendered + + +def test_context_template_one_provider_per_substate(): + """Each substate gets its own provider so one delta re-renders one context. + + A single provider owning every reducer means any delta recreates every + ``StateContexts`` element; nesting one ``SubstateProvider`` per substate + keeps the untouched providers memoized. + """ + from reflex_base.compiler.templates import context_template + + rendered = context_template( + is_dev_mode=True, + default_color_mode='"light"', + initial_state={ + "reflex___state____state": {}, + "reflex___state____state__sub": {}, + }, + state_name="reflex___state____state", + ) + + assert ( + "createElement(SubstateProvider, {substateName: 'reflex___state____state', " + "contextName: 'reflex___state____state'}," in rendered + ) + assert ( + "createElement(SubstateProvider, {substateName: 'reflex___state____state__sub', " + "contextName: 'reflex___state____state__sub'}," in rendered + ) + # The reducers moved into SubstateProvider; StateProvider only composes. + provider_body = rendered[rendered.index("export function StateProvider") :] + assert "useReducer" not in provider_body + assert "createElement(DispatchProvider, {}," in provider_body + + +def test_context_template_dispatchers_registered_before_socket(): + """Dispatchers register in a layout effect, ahead of the socket connect. + + ``EventLoopProvider`` mounts below the state providers, so its passive + effect (which connects the websocket) runs before any ancestor's passive + effect. A delta naming a substate with no dispatcher is a fatal state + mismatch, so registration has to happen in the layout phase instead. + """ + from reflex_base.compiler.templates import context_template + + rendered = context_template( + is_dev_mode=True, + default_color_mode='"light"', + initial_state={"reflex___state____state": {}}, + state_name="reflex___state____state", + ) + + assert "useIsomorphicLayoutEffect(() => {" in rendered + assert "dispatchers[substateName] = dispatchSubstate;" in rendered + assert "delete dispatchers[substateName];" in rendered + # ``useLayoutEffect`` warns during SSR, where no effect runs at all. + assert ( + 'const useIsomorphicLayoutEffect =\n typeof document !== "undefined" ' + "? useLayoutEffect : useEffect;" in rendered + ) + # The registry is a plain object, not the ref wrapper around it. + assert "createElement(DispatchContext, { value: dispatchers.current }" in rendered From ca3d1215373d6ae6161f87466874c3a43f07a2f2 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 18 Sep 2026 13:15:16 -0700 Subject: [PATCH 2/3] Cover initial multi-substate dispatch in the browser --- packages/reflex-base/news/6181.performance.md | 2 +- .../src/reflex_base/compiler/templates.py | 5 +- .../tests_playwright/test_router_query.py | 56 ++++++++++++++++++- tests/units/compiler/test_compiler.py | 29 ---------- 4 files changed, 57 insertions(+), 35 deletions(-) diff --git a/packages/reflex-base/news/6181.performance.md b/packages/reflex-base/news/6181.performance.md index b2caf5c376e..920f8b428c6 100644 --- a/packages/reflex-base/news/6181.performance.md +++ b/packages/reflex-base/news/6181.performance.md @@ -1 +1 @@ -Give each substate its own React context provider in the generated `utils/context.js`. A state delta now re-renders only the provider for the substate it touched, instead of recreating every `StateContexts` element under a single combined provider. Dispatchers register into a stable shared registry during the layout phase, so they are in place before the event loop connects the websocket. +Give each substate its own React context provider in the generated `utils/context.js`. A state delta now re-renders only the provider for the substate it touched, instead of recreating every `StateContexts` element under a single combined provider. diff --git a/packages/reflex-base/src/reflex_base/compiler/templates.py b/packages/reflex-base/src/reflex_base/compiler/templates.py index a486d15bd63..d4b3af8ebc5 100644 --- a/packages/reflex-base/src/reflex_base/compiler/templates.py +++ b/packages/reflex-base/src/reflex_base/compiler/templates.py @@ -522,13 +522,12 @@ def context_template( return useMemo( () => createElement(DispatchContext, {{ value: dispatchers.current }}, children), - [children, dispatchers], + [children], ); }}; // One provider per substate: each owns its own reducer, so a delta for one -// substate only re-renders that substate's context consumers instead of every -// consumer in the tree. +// substate only re-renders its provider instead of recreating every provider. const SubstateProvider = ({{ children, substateName, contextName }}) => {{ const dispatchers = useContext(DispatchContext); const [state, dispatchSubstate] = useReducer( diff --git a/tests/integration/tests_playwright/test_router_query.py b/tests/integration/tests_playwright/test_router_query.py index 50b83b94b25..96a70c27d34 100644 --- a/tests/integration/tests_playwright/test_router_query.py +++ b/tests/integration/tests_playwright/test_router_query.py @@ -24,6 +24,8 @@ import pytest from playwright.sync_api import Page, expect +from reflex_base.constants.state import FIELD_MARKER +from socketio.packet import Packet from reflex.testing import AppHarness @@ -32,6 +34,11 @@ def RouterQueryApp(): """App exercising replaceState, redirect, and redirect(replace=True).""" import reflex as rx + class ConnectedState(rx.State): + """A sibling updated by the first on-load event after connecting.""" + + connected_count: int = 0 + class RouterQueryState(rx.State): # Incremented by the page on_load handler; proves whether a navigation # (and thus on_load) actually fired. @@ -59,9 +66,11 @@ def name_param(self) -> str: return self.router.url.query_parameters.get("name", "") @rx.event - def on_load(self): - """Record that the page on_load handler fired.""" + async def on_load(self): + """Update both substates in the same on-load delta.""" self.load_count += 1 + connected = await self.get_state(ConnectedState) + connected.connected_count += 1 @rx.event def replace_via_script(self, query: str): @@ -141,6 +150,11 @@ def index(): read_only=True, id="load-count", ), + rx.input( + value=f"{ConnectedState.connected_count}", + read_only=True, + id="connected-count", + ), rx.input( value=f"{RouterQueryState.ping_count}", read_only=True, @@ -196,6 +210,44 @@ def _load(harness: AppHarness, page: Page) -> str: return base +def test_initial_connection_dispatches_both_substates( + router_query_app: AppHarness, page: Page +): + """The first on-load delta updates both sibling substates in the browser. + + Args: + router_query_app: Running application in dev or production mode. + page: Browser page receiving the initial connection's state updates. + """ + frames: list[str | bytes] = [] + page.on( + "websocket", + lambda socket: socket.on("framereceived", lambda frame: frames.append(frame)), + ) + + _load(router_query_app, page) + expect(page.locator("#connected-count")).to_have_value("1") + + # Socket.IO event packets use the Engine.IO message and event prefixes, 42. + for frame in frames: + if not isinstance(frame, str) or not frame.startswith("42"): + continue + packet = Packet(encoded_packet=frame[1:]) + assert packet.data is not None + event, update = packet.data + if event != "event": + continue + updated_substates = sum( + fields.get("load_count" + FIELD_MARKER) == 1 + or fields.get("connected_count" + FIELD_MARKER) == 1 + for fields in update.get("delta", {}).values() + ) + if updated_substates == 2: + break + else: + pytest.fail("The initial on-load update did not contain both substates") + + def test_replace_state_is_not_reactive_but_next_event_syncs( router_query_app: AppHarness, page: Page ): diff --git a/tests/units/compiler/test_compiler.py b/tests/units/compiler/test_compiler.py index 24546b3eebe..446d6cea424 100644 --- a/tests/units/compiler/test_compiler.py +++ b/tests/units/compiler/test_compiler.py @@ -1702,35 +1702,6 @@ def test_context_template_one_provider_per_substate(): assert "createElement(DispatchProvider, {}," in provider_body -def test_context_template_dispatchers_registered_before_socket(): - """Dispatchers register in a layout effect, ahead of the socket connect. - - ``EventLoopProvider`` mounts below the state providers, so its passive - effect (which connects the websocket) runs before any ancestor's passive - effect. A delta naming a substate with no dispatcher is a fatal state - mismatch, so registration has to happen in the layout phase instead. - """ - from reflex_base.compiler.templates import context_template - - rendered = context_template( - is_dev_mode=True, - default_color_mode='"light"', - initial_state={"reflex___state____state": {}}, - state_name="reflex___state____state", - ) - - assert "useIsomorphicLayoutEffect(() => {" in rendered - assert "dispatchers[substateName] = dispatchSubstate;" in rendered - assert "delete dispatchers[substateName];" in rendered - # ``useLayoutEffect`` warns during SSR, where no effect runs at all. - assert ( - 'const useIsomorphicLayoutEffect =\n typeof document !== "undefined" ' - "? useLayoutEffect : useEffect;" in rendered - ) - # The registry is a plain object, not the ref wrapper around it. - assert "createElement(DispatchContext, { value: dispatchers.current }" in rendered - - def test_context_template_client_side_component_is_named(): """``ClientSide`` returns a named component, not an anonymous arrow.""" from reflex_base.compiler.templates import context_template From 8b23c9add0abee8b1d98fa1211e858edaa3a0319 Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 18 Sep 2026 14:53:22 -0700 Subject: [PATCH 3/3] Describe state provider performance in user-facing terms --- packages/reflex-base/news/6181.performance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/reflex-base/news/6181.performance.md b/packages/reflex-base/news/6181.performance.md index 920f8b428c6..6c431a63ac9 100644 --- a/packages/reflex-base/news/6181.performance.md +++ b/packages/reflex-base/news/6181.performance.md @@ -1 +1 @@ -Give each substate its own React context provider in the generated `utils/context.js`. A state delta now re-renders only the provider for the substate it touched, instead of recreating every `StateContexts` element under a single combined provider. +Reduce browser rendering overhead for state updates, especially in apps with many substates.