Streamlining Game State Management in React with TypeScript Guarded Context

euhenio_el_ehores

Euhenio El Ehores

Posted on November 22, 2024

Streamlining Game State Management in React with TypeScript Guarded Context

While working on the frontend application for a game engine, I often encountered recurring problems related to guarding state types within my components.

To start, I had a simple state structure like this:

type RegularGameState = {  
  type: 'regular';  
} & any;  

type TournamentGameState = {  
  type: 'tournament';
} & any;    

const isTournamentGameState = (  
  state: GameState  
): state is TournamentGameState => state.type === 'tournament';

type GameState = RegularGameState | TournamentGameState;
Enter fullscreen mode Exit fullscreen mode

Depending on the game type, I needed to implement corresponding features.
However, my code quickly became cluttered:

const GameView = () => {
  const [state, setState] = useState<GameState>(getInitialState());
  const isTournament = useMemo(() => isTournamentGameState(state), [state]);

  useEffect(() => {
    if (!isTournamentGameState(state)) {
      return;
    }
    // Perform tournament-specific logic
  }, [state]);

  return (
    <>
      {isTournament && <SomeTournamentFeatureOne />}
      {isTournament && <SomeTournamentFeatureTwo />}
      {/* A memoized value doesn’t work as a TypeScript guard. As a result, in the second statement, the state's type will still be GameState. */}
      {isTournament && state.hasWinner && <TournamentWinnerView />}
    </>
  );
};
Enter fullscreen mode Exit fullscreen mode

This approach led to a scattered and repetitive codebase.

A Cleaner Solution: Using Context with TypeScript Guards
By refactoring the logic for these TypeScript guards and lifting it to the context level, we can make the code more organized and maintainable. Here's how it could look:

Refactored Context-Based Approach for Game State Management

To make the state management more streamlined and maintainable, we can use React context and TypeScript type guards at a higher level. Here's how:

const GameStateContext = createContext<GameState>(
  null as unknown as GameState
);

const GameStateContextProvider = ({ children }: { children?: React.ReactNode }) => {
  const [state, setState] = useState<GameState>(null);

  if (!state) {
    return null;
  }

  return (
    <GameStateContext.Provider value={state}>
      {children}
    </GameStateContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Adding Tournament-Specific Context. Now we can incapsulate tournament specific logic into children of this new Context.

const TournamentGameStateContext = createContext<TournamentGameState>(
  null as unknown as TournamentGameState
);

const TournamentGameStateContextGuard = ({ children }: { children: React.ReactNode }) => {
  const gameState = useContext(GameStateContext);

  if (!isTournamentGameState(gameState)) {
    return null;
  }

  return (
    <TournamentGameStateContext.Provider value={gameState}>
      {children}
    </TournamentGameStateContext.Provider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Scoping Effects into Renderless Components

To further simplify the code, we can encapsulate tournament-specific effects into renderless components. This allows us to completely omit the isTournamentGameState guard at the hook level:

const TournamentEffect = () => {
  const state = useContext(TournamentGameStateContext);

  useEffect(() => {
    // Perform tournament-specific logic here
  }, [state]);

  return null;
};
Enter fullscreen mode Exit fullscreen mode

Putting It All Together

With these abstractions, the GameContainer becomes cleaner and more modular. Tournament-specific logic and UI are scoped under the TournamentGameStateContextGuard, and common game views remain unaffected:

const GameContainer = () => {
  return (
    <GameStateContextProvider>
      {/* Tournament-specific components are isolated */}
      <TournamentGameStateContextGuard>
        <TournamentEffect />
        <TournamentLeaderboard />
      </TournamentGameStateContextGuard>
      {/* Shared components for all game types */}
      <CommonGameView />
    </GameStateContextProvider>
  );
};
Enter fullscreen mode Exit fullscreen mode

Benefits of This Approach

  • Context-Aware Components: Tournament-specific logic is scoped to components within the TournamentGameStateContext.
  • Reduced Repetition: No need to repeatedly check isTournamentGameState at multiple levels.
  • Clean Component Tree: Clear separation between common and tournament-specific components.

The biggest downside of this approach, is the lack of safeguards to prevent placing StateBasedFeaturingComponents outside of their specific guarded context.

If anyone knows how to enhance this approach—perhaps by adding custom ESLint rules or another solution—please share your ideas in the comments section!

💖 💪 🙅 🚩
euhenio_el_ehores
Euhenio El Ehores

Posted on November 22, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related