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;
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 />}
</>
);
};
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>
);
};
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>
);
};
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;
};
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>
);
};
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!
Top comments (0)