Skip to content

Create separate context providing components for each state#6181

Draft
masenf wants to merge 1 commit intomainfrom
masenf/memoize-state-provider-separately
Draft

Create separate context providing components for each state#6181
masenf wants to merge 1 commit intomainfrom
masenf/memoize-state-provider-separately

Conversation

@masenf
Copy link
Collaborator

@masenf masenf commented Mar 16, 2026

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 StatesProvider. Since the StatesProvider doesn't directly depend on each state's context, it doesn't have to re-render just because some substate changed.

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 StatesProvider. Since the StatesProvider doesn't
directly depend on each state's context, it doesn't have to re-render just
because some substate changed.
@codspeed-hq
Copy link

codspeed-hq bot commented Mar 16, 2026

Merging this PR will not alter performance

✅ 8 untouched benchmarks


Comparing masenf/memoize-state-provider-separately (b78ebc6) with main (7607fa3)

Open in CodSpeed

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 16, 2026

Greptile Summary

This PR refactors the React state management in the generated context.js to isolate each substate into its own StateProvider component with an independent useReducer, wrapped by a new StatesProvider composite. Previously, all state reducers and dispatchers were combined in a single StateProvider, meaning any substate change caused every context consumer to re-render. Now, a change in one substate only re-renders components subscribing to that specific context.

Key changes:

  • StateProviderStatesProvider (renamed export, used in app_root_template)
  • New DispatchProvider wraps a useRef({}) as a shared mutable dispatcher registry
  • Each StateProvider uses useEffect to register/unregister its reducer dispatch function on the shared ref
  • The useRef-based dispatch object is stable across renders, preventing unnecessary re-renders of EventLoopProvider

Potential issues found:

  • The useRef object is used unconventionally — properties are set directly on the ref rather than on .current, which works but may confuse maintainers
  • Dispatcher registration via useEffect creates a theoretical timing gap where the event loop could attempt to dispatch before all state reducers are registered (e.g., during fast reconnects)

Confidence Score: 3/5

  • This PR improves render performance but introduces a subtle timing dependency in dispatcher registration that should be validated.
  • The architectural change is sound and the parenthesis balancing in the template is correct. However, the shift from synchronous dispatcher availability (useMemo) to async registration (useEffect) introduces a theoretical race condition with the event loop, and the unconventional use of the ref object as a plain dictionary could trip up maintainers. The change only affects one generated file (context.js), limiting blast radius.
  • Pay close attention to reflex/compiler/templates.py (the StateProvider and DispatchProvider components) and reflex/.templates/web/utils/state.js (the consumer of dispatchers via dispatch[substate]).

Important Files Changed

Filename Overview
reflex/compiler/templates.py Refactors state context providers: each substate gets its own StateProvider component with independent useReducer, replacing the previous monolithic approach. Dispatchers are now registered via useEffect into a shared mutable ref, which introduces a potential timing gap with the event loop.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    SP["StatesProvider"]
    DP["DispatchProvider\n(useRef mutable dispatch registry)"]
    SP1["StateProvider (state 1)\nuseReducer + useEffect to register dispatcher"]
    SP2["StateProvider (state 2)\nuseReducer + useEffect to register dispatcher"]
    ELP["EventLoopProvider\nuseContext(DispatchContext) → dispatch ref"]
    AW["AppWrap (children)"]
    WS["WebSocket (state.js)\ndispatch[substate](delta)"]

    SP --> DP
    DP --> SP1
    SP1 --> SP2
    SP2 --> ELP
    ELP --> AW
    ELP -.->|"passes dispatch ref"| WS
    SP1 -.->|"registers dispatcher via useEffect"| DP
    SP2 -.->|"registers dispatcher via useEffect"| DP
    WS -.->|"calls dispatch[substate](...)"| DP
Loading

Last reviewed commit: b78ebc6

Comment on lines +395 to +397
const DispatchProvider = ({{ children }}) => {{
const dispatchers = useRef({{}});
return useMemo(() => createElement(DispatchContext, {{ value: dispatchers }}, children), [children, dispatchers]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Ref object used as plain dictionary

DispatchProvider passes the useRef({}) return value (a ref object with shape { current: {} }) as the context value. Then in StateProvider (line 404), dispatchers are set directly on this ref object (dispatchers[state_name] = dispatch_state) rather than on dispatchers.current. Similarly, EventLoopProvider and state.js access dispatch[substate] directly.

This works because the ref object is a stable mutable JS object, but it's unconventional — the .current property initialized to {} goes unused, and properties are set directly on the ref itself. A plain useRef(null) or useMemo(() => ({}), []) would make the intent clearer. Alternatively, if .current is meant to be the container, both the reads and writes should go through .current:

const DispatchProvider = ({ children }) => {
  const dispatchers = useRef({});
  return useMemo(() => createElement(DispatchContext, { value: dispatchers.current }, children), [children, dispatchers]);
}

…and update StateProvider to match (dispatchers.current[state_name] = ...).

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +400 to +416
const StateProvider = ({{ children, state_name, ctx_name }}) => {{
const dispatchers = useContext(DispatchContext);
const [state, dispatch_state] = useReducer(applyDelta, initialState[state_name]);
useEffect(() => {{
dispatchers[state_name] = dispatch_state;
return () => delete dispatchers[state_name];
}}, [dispatchers, dispatch_state, state_name]);
return useMemo(
() =>
createElement(
StateContexts[ctx_name],
{{ value: state }},
children,
),
[children, state, ctx_name],
);
}}
Copy link
Contributor

Choose a reason for hiding this comment

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

Dispatcher registration via useEffect creates a timing gap

Each StateProvider registers its dispatcher in a useEffect (line 403-405). Since useEffect runs after paint and React commits effects bottom-up (children first), the EventLoopProvider's socket-connecting effect (in state.js line 997-1011) fires before the StateProvider effects that register dispatchers.

In practice, the websocket connection is async so dispatchers are likely registered by the time the first "event" message arrives. However, if the socket connects very quickly (e.g. reconnect scenario), state.js line 681 (dispatch[substate](...)) could throw a TypeError because the dispatcher for that substate isn't registered yet on the ref object.

Consider using useLayoutEffect instead of useEffect for the dispatcher registration to ensure dispatchers are available synchronously after render, before any child effects or event handlers run.

@masenf masenf marked this pull request as draft March 16, 2026 19:33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant