Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dbcefc2
fix: reduce client state provider nesting
harsh21234i Oct 6, 2026
422610f
fix: avoid copying all client substates per delta
harsh21234i Oct 6, 2026
ab5f028
fix: flatten large state provider trees
harsh21234i Oct 8, 2026
ef50437
fix: address state provider review feedback
harsh21234i Oct 8, 2026
8543148
docs: add PR 7441 performance news fragment
harsh21234i Oct 8, 2026
8510eca
docs: add reflex package performance news fragment
harsh21234i Oct 8, 2026
6cd23f8
docs: use valid orphan news fragment name
harsh21234i Oct 8, 2026
4e9f746
fix: preserve legacy state context reads
harsh21234i Oct 10, 2026
fa529f8
Merge main into large state tree PR
harsh21234i Oct 10, 2026
2ec065b
fix: keep legacy state context reads compatible
harsh21234i Oct 10, 2026
009ca89
Fix compiler pre-commit checks for legacy context rewrite
masenf Oct 10, 2026
6b9819f
Merge main into flat state provider PR
masenf Oct 13, 2026
d8555c1
Merge branch 'main' into harsh21234i-fix-large-state-tree
harsh21234i Oct 14, 2026
543b086
Install greenlet in legacy playground check
harsh21234i Oct 14, 2026
ef580fe
Address PR 7441 review feedback
harsh21234i Oct 14, 2026
c84ded2
Fix remaining PR 7441 review findings
harsh21234i Oct 14, 2026
7a9cbeb
Fix legacy state read scanning and rewriting
harsh21234i Oct 14, 2026
6f0117c
fix: remove legacy StateContexts rewriter
FarhanAliRaza Oct 14, 2026
f534712
chore: drop breaking fragment for internal StateContexts removal
FarhanAliRaza Oct 14, 2026
1455221
fix: address review findings on the shared state store
FarhanAliRaza Oct 14, 2026
99f9ed8
test: subscribe through useStateContext in the store test
FarhanAliRaza Oct 14, 2026
62cffad
refactor: seed the state store from its argument
FarhanAliRaza Oct 14, 2026
3084bc4
fix: merge new default fields into live substates on hot reload
FarhanAliRaza Oct 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ jobs:
- name: Install reflex 0.8.23 into a fresh venv
run: |
uv venv --python 3.12 "$RUNNER_TEMP/reflex-0.8.23"
uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0"
uv pip install --python "$RUNNER_TEMP/reflex-0.8.23/bin/python" "reflex==0.8.23" "plotly>=6.0,<7.0" greenlet
echo "$RUNNER_TEMP/reflex-0.8.23/bin" >> "$GITHUB_PATH"
- name: Show the reflex under test
working-directory: examples/playground
Expand Down
6 changes: 3 additions & 3 deletions docs/app/tests/frontend_quality.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_
bundle_library('quality-lazy-fixture')
print(compile_app_root(rx.el.div('Quality'))[1])
`], {cwd: root, encoding: "utf8"});
const setup = new Function("window", "React", "emotion_react", "utils_context", "utils_state", "loadFixture",
const setup = new Function("window", "React", "emotion_react", "utils_context", "utils_context_registry", "utils_state", "loadFixture",
rootCode.replace(/^import .*$/gm, "")
.replace(/^export default function/gm, "function")
.replace(/^export /gm, "")
Expand All @@ -165,7 +165,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_
try {
let calls = 0;
globalThis.window = {};
setup(window, react, {}, {}, {}, async () => {calls++; return {value: 42};});
setup(window, react, {}, {}, {}, {}, async () => {calls++; return {value: 42};});
assert.equal(calls, 0);
assert.equal(window.__reflex.react, react);
assert.equal(window.__reflex["quality-lazy-fixture"], undefined);
Expand All @@ -179,7 +179,7 @@ with RegistrationContext(), patch('reflex.compiler.compiler.get_config', return_

let attempts = 0;
globalThis.window = {};
setup(window, react, {}, {}, {}, async () => {
setup(window, react, {}, {}, {}, {}, async () => {
if (++attempts === 1) throw new Error("Temporary download failure");
return {value: 43};
});
Expand Down
1 change: 1 addition & 0 deletions news/7441.performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use one shared state-store context with per-substate subscriptions to keep client and server render trees shallow for apps with many state subclasses.
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Use one shared state-store context with per-substate subscriptions to keep client and server render trees shallow for apps with many state subclasses.
Comment thread
harsh21234i marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,12 @@
// a module the compiler rewrites: Vite re-executes a rewritten module on hot
// update, and every module it imports along with it, but this module imports
// nothing that can change and is never re-executed.
import { createContext } from "react";
import {
createContext,
useCallback,
useContext,
useSyncExternalStore,
} from "react";

export const ColorModeContext = createContext({
rawColorMode: "system",
Expand All @@ -15,33 +20,61 @@ export const ColorModeContext = createContext({
export const UploadFilesContext = createContext(null);
export const DispatchContext = createContext(null);
export const EventLoopContext = createContext(null);
export const StateStoreContext = createContext(null);
const stateContexts = new Map();

ColorModeContext.displayName = "ColorModeContext";
UploadFilesContext.displayName = "UploadFilesContext";
DispatchContext.displayName = "DispatchContext";
EventLoopContext.displayName = "EventLoopContext";

const stateContexts = new Map();
StateStoreContext.displayName = "StateStoreContext";

/**
* Return the context object carrying the named Python state.
*
* The first call for a name creates the context; later calls, including ones
* made from a re-executed generated module, return the same object.
* Return the stable legacy context for a named Python state.
*
* @param {string} name - The dotted Python state name.
* @returns {React.Context} The state's context object.
* @returns {React.Context} The named state's context object.
*/
export function getStateContext(name) {
let context = stateContexts.get(name);
if (context === undefined) {
context = createContext(null);
context.displayName = `StateContext(${name})`;
context.stateName = name;
stateContexts.set(name, context);
}
return context;
}

/**
* Subscribe a component to one Python substate in the shared store.
*
* @param {string} name - The dotted Python state name.
* @returns {object} The current state value for the named substate.
*/
export function useStateContext(name) {
const store = useContext(StateStoreContext);
const subscribe = useCallback(
(listener) => store.subscribe(name, listener),
[store, name],
);
const getSnapshot = useCallback(
() => store.getSnapshot(name) ?? app.initialState?.[name],
[store, name],
);
return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
}

/**
* Read a state through a legacy StateContexts entry using the shared store.
*
* @param {React.Context} context - The named state's stable context handle.
* @returns {object} The current state value.
*/
export function useLegacyStateContext(context) {
return useStateContext(context.stateName);
}

// Values the generated context module registers each time it executes. The
// static runtime reads them through this object at call time instead of
// importing the generated module, so a compile never re-executes the runtime.
Expand Down
153 changes: 79 additions & 74 deletions packages/reflex-base/src/reflex_base/compiler/templates.py
Original file line number Diff line number Diff line change
Expand Up @@ -344,7 +344,6 @@ def context_template(
f'{format_state_name(state_name)}: getStateContext("{state_name}"),'
for state_name in initial_state
])

state_str = (
rf"""
export const state_name = "{state_name}"
Expand Down Expand Up @@ -411,11 +410,6 @@ def context_template(
"""
)

substates_str = "".join(
f"\n ['{state_name}', '{format_state_name(state_name)}'],"
for state_name in initial_state
)

disable_owner_stacks_str = (
r"""
// Disable React dev-build owner-stack capture: the per-element Error()
Expand Down Expand Up @@ -444,12 +438,12 @@ def context_template(
else ""
)

return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useReducer, useRef, useState, createElement, useEffect, useLayoutEffect }} from "react"
return rf"""import {"React, " if disable_react_owner_stacks else ""}{{ useContext, useMemo, useState, createElement, useEffect }} from "react"
import {{ applyDelta, ReflexEvent, hydrateClientStorage, useEventLoop, refs }} from "$/utils/state"
import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, getStateContext, registerApp, eventLoop }} from "$/utils/context-registry"
import {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext, getStateContext, useStateContext, registerApp, eventLoop }} from "$/utils/context-registry"
import {{ jsx }} from "@emotion/react";
{disable_owner_stacks_str}
export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext }};
export {{ ColorModeContext, UploadFilesContext, DispatchContext, EventLoopContext, StateStoreContext }};
export const initialState = {initial_state_json}
export const initialStateHashes = {"[]" if not initial_state_hashes else json_dumps(initial_state_hashes)}

Expand Down Expand Up @@ -536,78 +530,89 @@ def context_template(
);
}}

// ``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],
);
}};

// One provider per substate: each owns its own reducer, so a delta for one
// substate only re-renders its provider instead of recreating every provider.
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 skipped with a warning.
useIsomorphicLayoutEffect(() => {{
dispatchers[substateName] = dispatchSubstate;
return () => {{
delete dispatchers[substateName];
}};
}}, [dispatchers, dispatchSubstate, substateName]);
return useMemo(
() => createElement(StateContexts[contextName], {{ value: state }}, children),
[children, state, contextName],
// Dispatch updates happen outside render; useSyncExternalStore keeps reads
// consistent while notifying only consumers of the changed substate.
function createStateStore() {{
const state = {{ ...initialState }};
Comment thread
FarhanAliRaza marked this conversation as resolved.
Outdated
const listeners = new Map();
const dispatchers = {{}};
const registerSubstate = (substateName, initialValue) => {{
if (!listeners.has(substateName)) {{
listeners.set(substateName, new Set());
}}
if (!Object.hasOwn(state, substateName)) {{
state[substateName] = initialValue;
}}
if (!Object.hasOwn(dispatchers, substateName)) {{
dispatchers[substateName] = (delta) => {{
const currentState = state[substateName];
if (
Object.keys(delta).every((key) =>
Object.is(currentState[key], delta[key]),
)
) {{
return;
}}
const nextState = applyDelta(currentState, delta);
state[substateName] = nextState;
listeners.get(substateName)?.forEach((listener) => listener());
}};
}}
}};
Object.entries(state).forEach(([substateName, value]) =>
registerSubstate(substateName, value),
);
Comment thread
FarhanAliRaza marked this conversation as resolved.
Outdated
}};

// ``[substateName, contextName]`` for every substate, outermost first.
const SUBSTATES = [{substates_str}
];
return {{
dispatchers,
syncInitialState: (nextInitialState) => {{
Object.entries(nextInitialState).forEach(([substateName, value]) => {{
const isNewSubstate = !Object.hasOwn(state, substateName);
registerSubstate(substateName, value);
const currentState = state[substateName];
const addedFields = Object.fromEntries(
Object.entries(value).filter(([key]) => !Object.hasOwn(currentState, key)),
);
if (Object.keys(addedFields).length > 0) {{
state[substateName] = {{ ...currentState, ...addedFields }};
}}
if (isNewSubstate || Object.keys(addedFields).length > 0) {{
listeners.get(substateName)?.forEach((listener) => listener());
}}
}});
}},
getSnapshot: (substateName) => state[substateName],
subscribe: (substateName, listener) => {{
let substateListeners = listeners.get(substateName);
if (!substateListeners) {{
substateListeners = new Set();
listeners.set(substateName, substateListeners);
}}
substateListeners.add(listener);
return () => substateListeners.delete(listener);
}},
}};
}}

function StateStoreProviders({{ children, store }}) {{
return createElement(
StateStoreContext,
{{ value: store }},
createElement(DispatchContext, {{ value: store.dispatchers }}, children),
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
);
}}

function ClientStateProvider({{ children }}) {{
return useMemo(() => {{
let tree = children;
for (let i = SUBSTATES.length - 1; i >= 0; i--) {{
const [substateName, contextName] = SUBSTATES[i];
tree = createElement(SubstateProvider, {{ substateName, contextName }}, tree);
}}
return createElement(DispatchProvider, {{}}, tree);
}}, [children]);
const [store] = useState(createStateStore);
Comment thread
FarhanAliRaza marked this conversation as resolved.
Outdated
useEffect(() => store.syncInitialState(initialState), [store, initialState]);
Comment thread
harsh21234i marked this conversation as resolved.
Outdated
return createElement(StateStoreProviders, {{ children, store }});
Comment thread
harsh21234i marked this conversation as resolved.
}}

// The server renders once and never applies a delta, so it provides the
// initial state through bare context providers. ``SubstateProvider`` would add
// a second render level per substate, and the server renderer recurses once per
// level, so with many substates rendering a page can exhaust the stack.
function ServerStateProvider({{ children }}) {{
let tree = children;
for (let i = SUBSTATES.length - 1; i >= 0; i--) {{
const [substateName, contextName] = SUBSTATES[i];
tree = createElement(
StateContexts[contextName],
{{ value: initialState[substateName] }},
tree,
);
}}
return createElement(DispatchContext, {{ value: {{}} }}, tree);
return createElement(
StateStoreProviders,
{{ children, store: createStateStore() }},
);
}}
Comment thread
harsh21234i marked this conversation as resolved.

export const StateProvider =
Expand Down
1 change: 1 addition & 0 deletions packages/reflex-base/src/reflex_base/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ def _default_bundled_libraries() -> list[str]:
"react",
"@emotion/react",
f"$/{constants.Dirs.CONTEXTS_PATH}",
f"$/{constants.Dirs.UTILS}/context-registry",
f"$/{constants.Dirs.STATE_PATH}",
]

Expand Down
12 changes: 6 additions & 6 deletions packages/reflex-base/src/reflex_base/vars/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -636,19 +636,19 @@ def from_state(cls, state: type[BaseState] | str, field_name: str = "") -> VarDa
from reflex_base.utils import format

state_name = state if isinstance(state, str) else state.get_full_name()
state_var_name = format.format_state_name(state_name)
return VarData(
state=state_name,
field_name=field_name,
hooks={
"const {0} = useContext(StateContexts.{0})".format(
format.format_state_name(state_name)
): None
f"const {state_var_name} = useStateContext({json.dumps(state_name)})": None
Comment thread
harsh21234i marked this conversation as resolved.
},
imports={
f"$/{constants.Dirs.CONTEXTS_PATH}": [ImportVar(tag="StateContexts")],
"react": [ImportVar(tag="useContext")],
f"$/{constants.Dirs.UTILS}/context-registry": [
ImportVar(tag="useStateContext")
],
},
# State Vars read ``StateContexts``/``EventLoopContext``, so the
# State Vars read the shared store/``EventLoopContext``, so the
# providers must enclose every component that uses them.
app_wraps=get_event_app_wraps(),
)
Expand Down
Loading
Loading