Developing React/TypeScript Applications using Context API and Hooks (Guide with sample project)

Syedwshah
12 min readFeb 21, 2023

--

Click here to see the full code.

The goal of this article is to help Front End developers acquire baseline understanding of complex state management in a strictly typed environment. This will serve the purpose of minimizing confusion of the best way to handle large state as there are many patterns to solve this problem using Redux, MobX, MobX-State-Tree and so on… There is no right way to go about this, but the major selling point here for using Context and Hooks is that they can do everything those libraries do without additional installation and minimal boilerplate. Use of TypeScript will allow for type safety and improve your overall project’s scalability and performance.

This guide will touch up on build tools, data modeling, basic TypeScript usage. Link to the project code will include everything from start to finish.

In a real setting the sample project used in this guide does not require Context. The overall state management is simple enough to be passed down directly locally via the virtual DOM through callback props since this is a fairly small application.

Management of local state provides access to data within the same node, but using Context API allows for creation of a reference to an object provided to all child nodes. In other words data is passed down to children of a provider accessible by many child nodes contained within that High Order Component (HOC). So the core idea is the same, the key difference being how data can be accessed.

  • Creating a new typescript react project
  • Project set up
  • handling exports
  • creating UI components
  • creating a context
  • using the provider

Without further ado, let’s create the popular game Connect Four as a sample project. Start by creating a React application with Typescript using create-react-app as such:

Once this is executed we will have our entry point. Begin by cleaning up the boilerplate by deleting App.test.tsx, App.css and logo.svg as they will not be needed. Next will be cleanup of App.tsx:

//App.tsx
import React from 'react';

function App() {
return (
<div />
);
}
export default App;

Start the development of connect-four by updating our folder structure under /src directory (see image below). Notice providers are placed within the components folder, however it may be better placed elsewhere, and such a design choice should be refactored to meet your project needs.

To keep future imports clean there will be an index.ts file for each directory created to handle exports. The project source will be as follows…

For starters get all base UI components out of the way.

The UI of the game will be handled primarily by a custom Slot component comprised of the rules set forth by a generic Chip component. The algorithm handling the placement of chips into their respective slots will come later by Game. For the Chip folder the entire code is as follows:

//Chip.tsx
export enum ChipColors {
RED = 'red',
YELLOW = 'yellow',
}

interface Props {
color?: ChipColors
}
export const Chip = (props: Props): JSX.Element => {
return (
<div
id="chip"
style={{
display: 'flex',
height: 64,
width: 64,
zIndex: 20,
backgroundColor: props.color ?? '#fafafa',
borderRadius: '50%',
}}
/>
)
}
//index.ts for Chip
export * from './Chip'

This component is fairly straight forward. It renders a circular ‘red’ or ‘yellow’ disc should “color” be provided through props. Should there be no provided color we shall use the nullish coalescing (??) operator to return the right hand operand, or in this case “#fafafa” (off-white). The enum handing the colors could theoretically be placed in another directory, however the pattern used here is to keep all things pertaining to a Chip in one place.

And now for the slots handling the chips:

//Slot.tsx
import React, { useState } from 'react'
import { ChipColors, Chip } from './Chip'

interface Props {
color?: ChipColors
topRow?: boolean
onClick?: () => void
}

export const Slot = (props: Props): JSX.Element => {
const [hover, setHover] = useState(false)
return (
<div
id="slot"
style={{
...styles.slot,
cursor: props.topRow ? 'pointer' : 'auto',
backgroundColor: props.topRow && hover ? 'blue' : 'cyan',
}}
onClick={props.topRow ? props.onClick : undefined}
>
<div
id="hole-punch"
style={{
...styles.holePunch,
cursor: props.topRow ? 'pointer' : 'auto',
}}
onMouseEnter={() => {
setHover(true)
}}
onMouseLeave={() => setHover(false)}
>
<Chip color={props.color} />
</div>
</div>
)
}

const styles: Record<string, React.CSSProperties> = {
slot: {
display: 'flex',
alignItems: 'center',
backgroundColor: 'cyan',
height: 64,
width: 64,
padding: 2,
},
holePunch: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: 64,
width: 64,
borderRadius: '50%',
},
}
//index.ts for Slot
export * from './Slot'
export * from './Chip'

The prop topRow will come into play later down the line when we get into the game logic. This will be a check to see if the slot is in fact a top row element of our game’s board. The idea is that a chip will be dropped from the top row of the game board. To improve the UX there will be a color change based on mouse over events.

A particular line of interest from Slot.tsx is the optional onClick prop given to its Props interface. As a design choice every callback prop in React should be optional as there is no advantage requiring a callback prop! By making callback props optional we may eliminate the need for supplying a default anonymous callback and therefore write less code. It’s a win-win!

You may have noticed that the styles object is at the bottom. This is so that developers won’t have to scroll past style guides upon opening a react component. Over a long enough development this simple trick can save hours of scrolling.

Creating the Context and Provider can be done in the providers directory in GameProviders.tsx file. This file will be broken down as we move further along.

//GameProviders.tsx
import { createContext, ReactNode, useCallback, useMemo, useState } from 'react'
import { ChipColors } from '../Game/Slot'

export const [ROWS, COLS] = [6, 7]

const defaultGame: Game = {
playerOne: [],
playerTwo: [],
}

const defaultGameMeta: GameMeta = {
history: {},
turn: 0,
currCols: {
0: ROWS - 1,
1: ROWS - 1,
2: ROWS - 1,
3: ROWS - 1,
4: ROWS - 1,
5: ROWS - 1,
6: ROWS - 1,
},
}

export interface GameContextType {
game: Game
gameMeta: GameMeta
winner?: string
newGame?: () => void
playerMove?: (newRow: number, newCol: number) => void
setWinner?: (str: string) => void
}

const GameContext = createContext<GameContextType>({
game: defaultGame,
gameMeta: defaultGameMeta,
})

interface Props {
children?: ReactNode
}

const GameProvider = ({ children }: Props): JSX.Element => {
const { Provider } = GameContext

const [gameMeta, setGameMeta] = useState<GameMeta>(defaultGameMeta)
const [game, setGame] = useState<Game>(defaultGame)
const [winner, setWin] = useState<string | undefined>()

const newGame = useCallback(() => {
setGame(defaultGame)
setGameMeta(defaultGameMeta)
setWin(undefined)
}, [])

const setWinner = useCallback((s?: string) => {
setWin(s)
}, [])

const playerMove = useCallback(
(newRow: number, newCol: number) => {
const turn = gameMeta.turn

const player = !(turn % 2) ? 'playerOne' : 'playerTwo'
const chip = !(turn % 2) ? ChipColors.YELLOW : ChipColors.RED

setGame({
...game,
[player]: [
...game[player],
{
row: newRow,
col: newCol,
chip: chip,
},
],
})

setGameMeta({
...gameMeta,
history: {
...gameMeta.history,
[kvHelper(newRow, newCol)]: chip,
},
turn: turn + 1,

currCols: {
...gameMeta.currCols,
...{
[newCol]: gameMeta.currCols[newCol] - 1,
},
},
})
},
[game, gameMeta]
)

const contextValue = useMemo(
() => ({ game, playerMove, gameMeta, newGame, winner, setWinner }),
[game, playerMove, gameMeta, newGame, winner, setWinner]
)

return <Provider value={contextValue}>{children}</Provider>
}

type DroppedChip = {
row: number
col: number
chip?: ChipColors
}

type Game = {
playerOne: DroppedChip[]
playerTwo: DroppedChip[]
}

type GameMeta = {
history: History
turn: number
currCols: Record<number, number>
}

// History is unideal type safety, since anything could go into the key of type string
// This is a simple way to obtain colors properly without using complex algorithm
// Data is kept on type Game to maintain type safety.

// We may remove type Game and keep only GameMeta based on coding preferences
type History = Record<string, ChipColors>

export const kvHelper = (r: number, c: number) => `${r},${c}`

export { GameContext, GameProvider }

There’s a lot going on here so let’s break it down. This file has our types, provider, and even a helper function that may be better suited in a constants folder. To make it easier to get to the code needed the imports are at the top, exports at the bottom, and type definitions are declared either on the top or the bottom of the file based on relevance. The location of the types is up to your discretion.

The initial focus here is creating the Context:

const GameContext = createContext<GameContextType>({
game: defaultGame,
gameMeta: defaultGameMeta,
winner?: string
})

For the sake of this project the model for game is not necessary however this is here to showcase multiple objects within the same context. GameContextType is the data that models the context to be created. For reference here it is again:

export interface GameContextType {
game: Game
gameMeta: GameMeta
winner?: string
newGame?: () => void
playerMove?: (newRow: number, newCol: number) => void
setWinner?: (str: string) => void
}

Both game and gameMeta are responsible for storing data, and we may even call this GameContext a store for our app data. The newGame and playerMove callbacks will serve as our reducers to update the state held within the store. Once again notice that these can be optional and therefore the code did not require an anonymous callback function in the default object for this data:

const GameContext = createContext<GameContextType>({
game: defaultGame,
gameMeta: defaultGameMeta,
})

Now that we have created the Context by defining the model of the data as well as a default state we may now move forward with creating the provider. A provider is an HOC responsible for passing the references created by the Context. The references can be used in our TSX to provide data to children through props, conveniently named “value” in the Provider component out of the box.

So the tasks to be done for the provider portion are:

  • Create the provider
  • Pass the provider data to publicize through props
  • Place the Provider HOC into our App.tsx (the overwhelming majority of cases it will be here) to specify which nodes have access.

Here is the code for defining the provider (simplified view):

interface Props {
children?: ReactNode
}

const GameProvider = ({ children }: Props): JSX.Element => {
const { Provider } = GameContext

const [gameMeta, setGameMeta] = useState<GameMeta>(defaultGameMeta)
const [game, setGame] = useState<Game>(defaultGame)

const newGame = useCallback(() => {
//...code for handling new game
}, [])

const playerMove = useCallback(
(newRow: number, newCol: number) => {
//...code for handling player move
},
[game, gameMeta]
)

const contextValue = useMemo(
() => ({ game, playerMove, gameMeta, newGame }),
[game, playerMove, gameMeta, newGame]
)

return <Provider value={contextValue}>{children}</Provider>
}

The provider is not as scary as it may appear so let’s break it down. It is simply a React component, which is why its output return type is JSX.Element. For the props the return type of children is not a JSX.Element but an optional React.ReactNode, this is due to the fact that ReactNode encompasses several union types that may be annoying to otherwise write out such as:

type ReactChildren = JSX.Element | JSX.Element[] | ReactElement | null

Usage of an optional ReactNode allows us to not throw an error should there exist no child node or if multiple components are to be rendered at once. You should pass only one encompassing node to React’s render() method anyway, so you probably won’t need an array.

Circling back to the Provider, this object came from the createContext(). We may access it by dereferencing GameContext directly or declare a constant through a destructuring assignment:

const { Provider } = GameContext

Placing our hooks at the top, an opinionated requirement of Hooks, we are simply going down the GameContextType and repurposing our default state constants:

const [gameMeta, setGameMeta] = useState<GameMeta>(defaultGameMeta)
const [game, setGame] = useState<Game>(defaultGame)

These state setters are the point of interest for our reducers as they provide us access to update our data via callbacks.

const newGame = useCallback(() => {
setGame(defaultGame)
setGameMeta(defaultGameMeta)
}, [])

const playerMove = useCallback(
(newRow: number, newCol: number) => {
//...code for handling player move
},
[game, gameMeta]
)
const setWinner = useCallback((s?: string) => {
setWin(s)
}, [])

The use of a useCallback() and useMemo() hooks here is best explained under the section “Optimizing re-renders when passing objects and functions” found here.

Now we may place our HOC in App.tsx and therefore give access to the nested children of the provider, if any.

import { Game } from './components'
import { GameProvider } from './components/providers'
const App = (): JSX.Element => (
<GameProvider>
<Game />
</GameProvider>
)
export default App

Now is where we may create the game component, Game, and access the values provided through GameProvider.

Since there now exists a way to pass data through the component tree without having to pass props down manually at every level, we can make use of it as such:

//Game.tsx
import React, { CSSProperties, useContext, useEffect } from 'react'
import { COLS, GameContext, kvHelper, ROWS } from '../providers'

import { Slot } from './Slot'

export const Game = (): JSX.Element => {
const gameContext = useContext(GameContext)

useEffect(() => {
const board = gameContext.gameMeta.history
const setWinner = gameContext.setWinner

if (gameContext.gameMeta.turn === ROWS * COLS) {
setWinner?.('stalemate')
}

// if no winner, search winner
if (!gameContext.winner) {
// horizontal
for (let r = 0; r < ROWS; r++) {
for (let c = 0; c < COLS - 3; c++) {
const color: string = board[kvHelper(r, c)]
if (
color &&
color === board[kvHelper(r, c + 1)] &&
color === board[kvHelper(r, c + 2)] &&
color === board[kvHelper(r, c + 3)]
) {
setWinner?.(color)
}
}
}
}

// vertical
for (let c = 0; c < COLS; c++) {
for (let r = 0; r < ROWS - 3; r++) {
const color: string = board[kvHelper(r, c)]
if (
color &&
color === board[kvHelper(r + 1, c)] &&
color === board[kvHelper(r + 2, c)] &&
color === board[kvHelper(r + 3, c)]
) {
setWinner?.(color)
return
}
}
}

// neg-diagonal
for (let r = 0; r < ROWS - 3; r++) {
for (let c = 0; c < COLS - 3; c++) {
const color: string = board[kvHelper(r, c)]
if (
color &&
color === board[kvHelper(r + 1, c + 1)] &&
color === board[kvHelper(r + 2, c + 2)] &&
color === board[kvHelper(r + 3, c + 3)]
) {
setWinner?.(color)
return
}
}
}

/// diagonal
for (let r = 3; r < ROWS; r++) {
for (let c = 0; c < COLS - 3; c++) {
const color: string = board[kvHelper(r, c)]
if (
color &&
color === board[kvHelper(r - 1, c + 1)] &&
color === board[kvHelper(r - 2, c + 2)] &&
color === board[kvHelper(r - 3, c + 3)]
) {
setWinner?.(color)
return
}
}
}
}, [
gameContext.gameMeta.history,
gameContext.gameMeta.turn,
gameContext.setWinner,
gameContext.winner,
])

const turn = gameContext.gameMeta.turn
const player = !(turn % 2) ? 'Player 1' : 'Player 2'
const board = gameContext.gameMeta.history
const winner = gameContext.winner

const handleClick = (row: number, col: number) => {
const r = gameContext.gameMeta.currCols[col]

if (r >= 0 && !winner) {
gameContext.playerMove?.(r - row, col)
}
}

return (
<div id="game">
<div style={{ display: 'flex', flexDirection: 'column' }}>
{[...Array(ROWS)].map((_, rowIndex) => (
<div style={styles.flex} key={rowIndex}>
{[...Array(COLS)].map((_, colIndex) => (
<div key={`${rowIndex}-${colIndex}`}>
<Slot
color={board[kvHelper(rowIndex, colIndex)]}
topRow={rowIndex === 0}
onClick={() => handleClick(rowIndex, colIndex)}
/>
</div>
))}
</div>
))}
</div>

<div style={{ margin: 12 }}>
<p>{player}'s turn</p>
<p>{turn} Chips Dropped </p>
<p>winner: {winner}</p>

<p>
<b>How to play:</b> Hover cursor to top of board, click to drop chip
</p>

<button onClick={gameContext?.newGame}>New Game</button>
</div>
</div>
)
}

const styles: Record<string, CSSProperties> = {
flex: {
display: 'flex',
flexDirection: 'row',
},
bg: {
backgroundColor: '#fafafa',
},
}

Although all children of a provider may have access to the values contained within the provider service, they don’t necessarily require access to it. By default these components won’t have access unless specifically requested. This is because the children React nodes may have access to multiple contexts yet require some or none.

const gameContext = useContext(GameContext)

From here we can manipulate the gameContext object the same as any other object, as shown on the code above. And that’s it! Here’s the result:

Follow me for more! LinkedIn, Portfolio, StackOverflow, GitHub

Code to project in this article is here.

--

--

Syedwshah

Software Engineer with focus on Front-End/Back-End, DSA, Systems Design, API Design, Object Modeling, and DB Modeling