What's an efficient way to react to global state updates in a React app? If using Redux, you'd use a selector. But I don't use Redux for my puzzle game, as I have my own state object. It works similar to redux — I have an immutable state which is completely replaced on modification. All changes in the logical game state are done there.
I used React's contexts to subscribe to state changes for the UI. This works, except parts of my UI are needlessly rerendered. The context update is sent upon any change, even if that part of the UI doesn't care about it. In practice this isn't too bad for my game, since I have few components listening, and pass properties down to memoized components. Still, I don't like inefficiency, and I know useSelector
from other projects.
How could I get the selector logic in my own React code? I have a game state, and I know which parts I'm interested in, so it should be easy. I thought a long time about how it should be done, a lot more time that it took to finally implement. I'll cover what I did here, hopefully reducing the time you need to search for solutions.
What does React offer?
Somewhere in React is a subscription mechanism. That's how the components know to update when something changes. There are two options: context and state. They are both needed to build a selector.
Using a context is well documented. Nonetheless, here's a brief outline of how I used this prior to creating a selector. My actual code is TypeScript and has a layer of wrapping around this.
let GameContext = React.createContext([game_state, game_manager])
let game_manager = get_game_magically_from_global()
function MainComponent() {
// I use React's state system to track the game state within this component.
const [game_state, set_game_state] = React.useState(game_manager.get_current_state())
// My game manager needs to tell me when the state changes.
React.useEffect(() => {
game_manager.watch_state(set_game_state)
}, [set_game_state])
// Provide the current state value to the context to pass down through the tree
return (
<GameContext.Provider value={[game_state, game_manager]}>
<EdaqasRoomUI />
</GameContext>
)
}
function NiftyGameItem() {
const [game_state, game_manager] = React.useContext(GameContext)
const drop = React.useCallback(() =>
game_manager.drop_item()
}, [game_manager])
return (
<img onClick={drop} src={game_state.held_item.image} />
)
}
I provide both the current game state and the game manager in the context. The state is for reading and the context for providing feedback. This is similar to Redux's dispatcher; my game manager also uses messages to communicate with the state.
The State
Notice useState
in that example as well. For React, updating the context is no different than any other use of the state. The extra aspect of the context is providing that value to the descendents of the component. This is what the Provider
does.
State can be used without a context as well. Here's a simple example as a reminder.
function ExpandInventory() {
const [expanded, set_expanded] = React.useState(false)
const toggle = React.useCallback(() => {
set_expanded(!expanded)
}, [expanded, set_expanded])
return (
<>
<CompactView onClick={toggle} />
{expanded && <GloriousFullView />}
</>
)
}
When the user clicks on the compact view, the browser calls the toggle function, which modifies the state. When the state is modified React will rerender the control.
JSX files create an illusion of close cooperative harmony between this code, the state, and the HTML DOM. The truth is a lot uglier. The HTML goes through React's diff engine, then is assembled into the browser's DOM tree. The callback function lives in the global heap, connected to DOM object, as well as being a closure over the stack frame in which it was created. The closure will be called in response to a user's click, far away from the stack in which the render code was run.
Understanding this structure is the key to making our own selectors. That set_expanded
function can be called from anywhere and React will figure out how to update the component as a result.
Too many updates
Any component that needs the game state can call useContext(GameContext)
. The problem is that all state changes, whether they'd alter the component or not, cause the component to rerender. In my previous example, the NiftyGameItem
only needs to update when held_item
changes, yet currently it'll update anytime anything in the state changes. That's pointless and wasteful.
If I were using Redux, I'd use a selector to solve this issue.
const held_item = useSelector( game_state => game_state.held_item )
Only when game_state.held_item
changes will the component rerender.
useSelector
itself isn't magical. It is essentially a layer in between the state and the control. It will listen to every update to the game state, and run the selection function. But it will only update the component if the result of the selection function changes.
I wanted the same facility for my game state.
My own selector
useState
is the primary hook into React's subscription system. At first, I looked for an explicit subscription API. What I wanted to do isn't directly covered in the state docs. But as I mentioned before, understanding how the callbacks, DOM, and state connect, assures me that my approach is correct.
What is the goal? This is what I want my NiftyGameItem
to look like, ignoring the onClick
part for a moment.
function NiftyGameItem() {
const held_item = useGameState( gs => gs.held_item )
return (
<img src={game_state.held_item.image} />
)
}
I only want to update when held_item
changes. Let's jump right the almost final code.
type game_selector<T> = ( state : GT.game_state ) => T
export function useGameState<T>( gs : game_selector<T> ): T {
const [_, game_manager] = React.useContext(GameContext)
const [ state, set_state ] = React.useState<T>(():T => gs(game_manager.current_game_state()))
React.useEffect(() => {
const track = {
current: state,
}
return game_manager.listen_game_state( (game_state: GT.game_state) => {
const next: T = gs(game_state)
if (track.current != next) {
track.current = next
set_state(next)
}
})
}, [game_manager, set_state, gs])
return gs(state)
}
const [_, game_manager] = React.useContext(GameContext)
I get the game manger as I did before, but we'll have to come back and fix something here.
const [ state, set_state ] = React.useState<T>(():T => gs(game_manager.current_game_state()))
...
return state
I prep the state for the component. The game manager needs to provide the current state as it'll be needed when the component first renders, not only when the state updates. Here I don't track the entire game state, only the part that is of interest — the part extracted by the selector.
A selector function, gs
here, takes the global state as input and returns the part to be watched. My useGameState
code calls the gs
selector function with the global state. The selector in my example is gs => gs.held_item
, which retrieves only the held_item
. In the game I have an on-screen indicator showing which item the player is currently holding.
I return the state at the end of the function. In the first call, this will be the initial state. In subsequent calls, for each new render of the control, it'll be the current state.
return game_manager.listen_game_state( (game_state: GT.game_state) => {
The vital piece of code inside useEffect
is the call to listen_game_state
. I added this subscription function to the game_manager
. The game manager already knows when the state updates, since it has to update the context. Now it updates the context as well as calling all the registered listeners. I'll show this code a bit further below.
const track = {
current: state,
}
return game_manager.listen_game_state( (game_state: GT.game_state) => {
const next: T = gs(game_state)
if (track.current != next) {
track.current = next
set_state(next)
}
})
Each time the state updates, the caller provided selector function is called to select a part of the state. This is compared to what value it had previously, and only if it has changed do we call the set_state
function. If we were to call the set_state
function every time, then it'd be no better than the caller listening for every state change.
Note the return
. The listen_game_state
function returns an unsubscribe function, which will be called whenever the effect is reevaluated, or the component unmounts. The game manager shouldn't hold on to components that are no longer around.
React.useEffect(() => {
...
}, [game_manager, set_state, gs])
The useEffect
runs once when the control is mounted (or first rendered, more correctly). I have a dependency list of [game_manager, set_state, gs]
for correctness. Should one of those change the effect needs to be reevaluated to grab the new values. In practice, these dependencies never change.
useState outside of a component?
It may seem unusual to call the useState
function in something other than a react component. This type of chaining is allowed and expected. There's nothing special about calling useState
directly in the component, or inside a function called by the component. React will understand which component it is in and associate it correctly.
I've not looked into precisely how this works. My assumption is that it's a global value that tracks the current component. The hook functions inspect that variable to figure out where they are and to register the appropriate listeners. I don't see another option, since
useGameState
is a plain TS/JS function — the JSX compiler has no chance to modify it. In threaded languages this stack would need to be thread local, but JS is single threaded (workers, etc. get their own global space, making them effectively thread local).
My selector is a combination of existing React functions: useState
, useEffect
, and useContext
.
Hold on, there's a problem
I have an issue in the first line of the useGameState
function:
const [_, game_manager] = React.useContext(GameContext)
I reused the context from before, the one that provides the game state and the game manager. This is bad. Since it hooks into the game state context, this component will still be updated with every change of the state.
To fix this, I added a new context which contains only the game manager.
const game_manager = React.useContext(GameManagerOnly)
This game manager never changes for the life of the game, thus no needless updates will be triggered by the call to useContext
.
At this point I gain nothing by storing the game manager singleton in a context. I could simply refer to the global object from my
useGameState
code. However, to behave as a proper React app, I've left it in the context. This may also be important for your project, where the object isn't a singleton.
Save the batteries
Performance wasn't an issue for my game. Curiousity was part of the reason I wrote the selectors. The selectors do of course help; there were thousands of needless updates to components. Cutting back this processing time should help older machines, as well as saving battery power on tablets.
I'll continue to make optimizations where I see them. It may be inconsequential compared to the massive browser SVG rendering overhead, but there's nothing I can do about that. As my games get more complex the calculation will continue to increase. Keeping it performant can only help long term.
Plus, you know, curiousity. A solid reason to do something.
Check out how this all comes together in my game Edaqa's Room: Prototype. A collaborative online escape room full of puzzles, adventure, and probably no vampires.
Appendix: Game Manager subscription code
This is the listen_game_state
code called by useEffect
in useGameState
. I've removed details about how I connect to my state object, for simplicity. If you'd like a closer examination of that part, let me know.
export type game_state_listener = (gs: GT.game_state) => void
export class GameManager implements StateChanged {
gsl_id = 0
game_state_listeners: Record<number,game_state_listener> = {}
.
.
.
listen_game_state( listener: game_state_listener ): ()=>void {
this.gsl_id += 1
const nid = this.gsl_id
this.game_state_listeners[nid] = listener
return () => {
delete this.game_state_listeners[nid]
}
}
Subscription queues needn't be complex. On updates to the game state, the function below is called (part of the StateChanged interface
).
game_state_changed(game_state) {
if( this.set_game_store ) {
this.set_game_store(game_state)
}
for (const listener of Object.values(this.game_state_listeners)) {
listener(game_state)
}
}
The first line goes back to the game_manager.watch_state(set_game_state)
call at the start of this article. It's what updates the context storing the game state.
The loop is what tells all the useGameState
listeners that something has changed.
Top comments (0)