1

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);
});

Angus Ryer
  • 114
  • 2
  • 10
  • 1
    Hi there, Did you open this issue at Jotai on github? – Horacio Alexandre Fernandes Mar 31 '21 at 18:43
  • I haven't, but I did avoid the problem by changing the flow of my functions calls. I haven't posted an answer here because my solution doesn't explain why this was an issue in the first place. When I find an extra moment, I'll post on the Jotai github. Thanks Horacio! – Angus Ryer Apr 04 '21 at 19:30

0 Answers0