I'm making a game that, like most games, has various scenarios that can be triggered from anywhere else in the game.
I opted to use Jotai as I found it very easy to use as a global state management tool. Jotai atoms behave a bit like setState
and useContext
combined.
The Lead-up
I'm storing the player's energy in an atom. When the energy reaches 0
, a useEffect
in the <EnergyLevel />
component triggers a change in a "global event state" atom. That change is picked up the parent <Game />
component, which conditionally renders the <EndGame />
component if the global event state value is an "end game" value.
The user can click "Start Over", which resets the global event state value and a global "active screen" value, which triggers the main menu to be rendered. At this point, the global event state has been explicitly set to NONE
.
When the user clicks on "Load Last Game"--which sets a "loading state" atom--the parent <Game />
component re-renders and its useEffect
conditionally calls a derived, write-only atom that gets the last saved game from a database and sets all the other atoms to those values. It also resets any global state tracking atoms to the defaults for a freshly loaded game.
The Bug
Technically, everything actually renders as expected up until this point, but for one additional component: the <EndGame />
component is rendered again, after <Game />
is finished loading.
Looking into this further (and with many console.logs), I found that another render is triggered after the expected final render (after useEffect) of <Game />
, and during that unexpected phase, the global event atom value, which was explicitly set to NONE
, is somehow reset to the old event value.
The Question
What is causing the additional render cycle as I reference in the second paragraph of the The Bug section above? And why is the eventTriggeredOfType
atom value changing during this unexpected render?
The Code
Reproducible code with this odd behavior is sandboxed here. Just follow the instructions once loaded.
Here are some snippets to demonstrate the intended logic:
// here's the function that decreases playerEnergy, which is triggered by a button
// Game.tsx
const [playerEnergy, setPlayerEnergy] = useAtom(playerEnergyAtom);
const decreaseEnergy = () => {
setPlayerEnergy(playerEnergy <= 0 ? 0 : playerEnergy - 10);
};
// then I watch playerEnergy and set the an event state to NO_ENERGY if it is 0
// EnergyLevel.tsx
const [, setEventTriggeredOfType] = useAtom(eventTriggeredOfTypeAtom);
useEffect(() => {
if (playerEnergy <= 0) setEventTriggeredOfType(EventType.NO_ENERGY);
}, [playerEnergy, setEventTriggeredOfType]);
// here is the EndGame component that is only rendered when
// shouldTriggerEndGame returns true
// Game.tsx
const [shouldTriggerEndGame] = useAtom(shouldTriggerEndGameAction);
if (shouldTriggerEndGame) return <EndGame />;
// here is the shouldTriggerEndGame atom that checks the event state
// gameActions.ts (this file holds all the atoms)
export const shouldTriggerEndGameAction = atom((get) => {
// this is what returns stale atom value SOMETIMES
const eventTriggeredOfType = get(eventTriggeredOfTypeAtom);
return [
eventTriggeredOfType === EventType.NO_ENERGY
].some((triggerState: boolean): boolean => triggerState === true);
});
// here is the "Start Over" button handler that resets the EventType above:
// EndGame.tsx
const goBackToMainMenu = () => {
setEventTriggeredOfType(EventType.NONE);
// triggers a return to the main screen (always works)
setActiveScreen(Screen.NONE);
};
// these are triggered by UI buttons from the user
// gameActions.ts
export const startNewGameAction = atom(null, (_get, set) => {
set(resetDefaultGameState, null);
set(isLoadingGameOfTypeAtom, LoadType.NEW);
});
export const loadLastGameAction = atom(null, (_get, set) => {
set(resetDefaultGameState, null);
set(isLoadingGameOfTypeAtom, LoadType.SAVED);
});
// isLoadingGameOfType is being watched from within <Game />
// Game.tsx
const loadSavedGameRef = useRef(loadSavedGame);
const createNewGameRef = useRef(createNewGame);
useEffect(() => {
if (isLoadingGameOfType === LoadType.SAVED) loadSavedGameRef.current();
if (isLoadingGameOfType === LoadType.NEW) createNewGameRef.current();
}, [isLoadingGameOfType]);
// these are triggered by <Game />'s useEffects when the appropriate
// eventTriggeredOfType values are true
// gameActions.ts
export const loadSavedGameAction = atom(null, async (get, set) => {
const playerData = get(playerDataAtom);
set(playerEnergyAtom, playerData.lastGameState.playerEnergy);
});
export const createNewGameAction = atom(null, (_get, set) => {
set(playerEnergyAtom, 100);
});