Type of Tic Tac Toe
We routinely write functions which have nicely defined inputs and outputs.
function putStatus(job:Id, status:'doing' | 'done' | 'revoked'): Result<Job> {
/* Dense validation logic */
}
More often that not we then stitch these together with some runtime logic to
satisfy our product goals.
But sometimes this can be taken further. Instead of a single output that can be arbitrarily
given to the next phase of application processing, or added to a queue, what if
our process and it progression was goverend by some rules? The next phases of the
system could be encoded in the output so you could only ever call a valid chain
of operations.
Consider Tic-tac-toe. It has a definite number of players - namely two, “X” and “O”.
We could encode these as types and refer to them collectively as “Player”.
type X = "X"
type O = "O"
type Player =
| X
| O
The game is played on a 3x3 board. Here I’ve encoded this as compass directions but
you could also encode this as 0-8/1-9. Like Player above, the positions are collectively
referred to by the Position type.
type N = "N"
type NE = "NE"
type E = "E"
type SE = "SE"
type S = "S"
type SW = "SW"
type W = "W"
type NW = "NW"
type Position =
| N
| NE
| E
| SE
| S
| SW
| W
| NW
The game is played in turns. During each turn a player puts their representative piece
on an unoccupied position of the board.
Naturally the game is represented by a map of postition to player.
type Board =
Map<Position, Player>
This where we start to cross from pure problem domain into the solution domain. The
State type represents the current state of the game: the board and whos turn it is.
Each time a turn is played these will change: the board will update with a new mapping
from position to player and the player will change.
type State<Available extends Position, NextPlayer extends Player> =
| Win<Player>
| Draw
| {
board: Board
player: NextPlayer
playTurn: <Target extends Available>(position:Target) => Next<Target,Available,NextPlayer>
}
The interesting piece of incidental complexity is the playTurn function. Remember that
at each step we want to be able to determine the valid set of next steps. At each step
the set of available positions will decrease and there may or may not be a winner (ttt can
end in a draw if all spaces are taken up).
In order to capture those details we can no longer have a single playTurn function as the
types of Position and Player capture the entire set of options. In other words the using
that single function the game would never progress because the input set would never be
diminishing. Instead what we do is return a new playTurn function describing the new
state of events and what positions can be played or even if the game has finished.
type Next<Target extends Available, Available extends Position, Current extends Player> =
State<Exclude<Available, Target>, Exclude<Player, Current>>
function createState<Available extends Position, CurrentPlayer extends Player>(board:Board, player:CurrentPlayer):State<Available,CurrentPlayer> {
return {
board,
player,
playTurn: <Target extends Available>(position:Target):Next<Target,Available,CurrentPlayer> | Win<Player> | Draw => {
board.set(position,player)
if (won(player))
return Win(player)
if (draw())
return Draw()
let nextPlayer = (player === "O" ? "X" : "O") as Exclude<Player,CurrentPlayer>
return createState(board, nextPlayer)
}
}
}
The createState type constructor captures this intent. Given the available positions, Available, the playTurn
function defines Target as a subset of Available. When the playTurn function is called the next state
uses the Target position to define the next set of Available positions, namely, it’s those unplayed
positions minus the just played Target position.
Determining the player is trivial, it’s just the exclusion of the current player from the set of all players.
In the case of tic-tac-toe there’s only two players so this functions to swap the players each turn.
Interestingly, because we’ve defined the position argument to be an unplayed position, we don’t have to
check it before we set it in the board map. In less strict definitions this might require runtime checks
for correctness.
For each turn we check to see if they player has won, or all positions have been used up in which case a draw
is called. If not the we know the game is still ongoing and a new state comprising a change in player and reduced
set of positions to play is created.
As a bit of house-keeping I also use a function to start the game off. It creates the board and sets the first
move before calling into the regular createState game loop.
function start<Target extends Position, FirstPlayer extends Player>(position: Target, player:FirstPlayer):Next<Target,Position,FirstPlayer> {
let board = new Map<Position, Player>
board.set(position, player)
let nextPlayer = (player === "O" ? "X" : "O") as Exclude<Player,FirstPlayer>
return createState(board, nextPlayer)
}
You don’t technically need it but I think it makes the API nicer to use. Also, you don’t technically need the
board in the game state. Since it’s handled by the createState function and never directly associated with
input it could just be invisibly passed from one game state to the next. In fact, because the current player
is automatically enforced by the rules and swaps each turn, it too is superfluous - all you really need is to
accept a position argument.
Although somewhat complex, this approach of statically locking down APIs via a state machine is incredibly
powerful. It’s only really limited by the applicability of your partciular problem. In this case there are
clearly defined rules of progression and the state-space is small enough that we can represent it here.
Even if you don’t use a fully-fledged state machine as here, accurate domain modelling and recognising the states
of our domain and how the interact/overlap will go a long way to creating more robust, maintainable software.

useState Obsession
So often I see components with large useState “clumps” like this
function SomeComponent() {
// State for
let [ swatches, showSwatches ] = useState(false);
let [ layers, showLayers ] = useState(false);
let [ fonts, showFonts ] = useState(false);
let [ tools, showTools ] = useState(false);
// State to
let [ stateA, setStateA ] = useState(false);
let [ stateB, setStateB ] = useState(false);
let closeSwatches = () => {
showSwatches(false);
showLayers(false);
// Either tools or swatches *must* be open
openTools();
}
let openTools = () => {
showTools(true);
showFonts(false);
}
let closeTools = () => {
openSwatches();
}
// On and on for all the handlers of valid sets of panes
// ...
return (
// ...
)
}
In this example swatches, layers, fonts and tools constitute a logical “clump” - the pieces of state representing whether some
associated UI “pane” is rendered. In a regular component there could be many of these logical groups. As well as the state, there’s the handler
functions which typically represent the events, they correspond to particular changes in state i.e. when openTools occurs we show the tools pane
and hide/collapse the fonts pane.
Impact
As the amount of state and associated handlers grows (of which there could be many; it’s not necessarily 1:1), maintainability, comprehension
and cohesion are negatively impacted. Encoding a complex UI arrangement through an ensemble of coordinated useState holding primitive values
forces complexity into our handlers and increases the complexity of the component too.
An alternative
A more maintainable approach is using a richer data type. Instead of encoding the data as primitives, use an object
function SomeComponent() {
let [ panes, setPanes ] = useState({
swatches: false,
layers: false,
fonts: false,
tools: false
});
let [ stateA, setStateA ] = useState(false);
let [ stateB, setStateB ] = useState(false);
let openTools = () => {
setPanes(panes => ({
...state,
tools: true,
fonts: false
});
}
let closeSwatches = () => {
setPanes(panes => ({
...state,
swatches: false,
layers: false
});
}
return (
//...
)
}
This more accurately captures the intent that these pieces of data should be used in concert as the mechanism for rendering the panes. However, this
approach still suffers from the event handlers polluting the component definition.
Furher along the information hiding route, we could use useReducer and it might look something like this –
// This function is the "reducer". Given some action/message
// and the current state, it produces a new state
const OPEN = true;
const CLOSED = false;
let reducer = (action, state) => {
switch(action) {
case 'OPEN_TOOLS': {
return {
...state,
tools: OPEN,
fonts: CLOSED
}
}
// remaining state transitions...
}
}
function SomeComponent() {
let [ { paneA, paneB, paneC, paneD }, dispatch ] = useReducer(reducer);
let [ stateA, setStateA ] = useState(false);
let [ stateB, setStateB ] = useState(false);
return (
<>
{/* ... */}
{ tools && <Pane.Tools onOpen={() => dispatch('OPEN_TOOLS')} /> }
{ fonts && <Pane.Fonts onOpen={() => dispatch('OPEN_FONTS')} /> }
{/* ... */}
</>
)
}
SomeComponent becomes more descriptive; all the details of rules for what panes are opened/closed and the associated mechanisms are tucked away in the reducer function
and we communicate via messages defined by that function. I think the intent and comprehension is now significantly clearer.
Wrap up
You may be thinking this is all a load of faff; they’re functionally equivalent, so what’s the point. I would say it’s the same argument against God objects but at a smaller
scale. Decomposing and making design decisions in order to minimise local complexity is always going to win out in the long term.
A interesting question to ask would be, why do we get these useState clumps? Of course I’m not exactly sure but my suspicion is that “primitive obsession” dovetails nicely
into useState - whether someone only thinks in useState which influences the design of the model, or the model is designed with primitives which are naturally represented
with useState is the other question. Though, it’s easy to understand why useState is the state primitive most close at hand (in the Simple Made Easy sense) - it says
“state” on the tin. The other one has “reducer” in the title. Pure marketing fail.
If that’s the case maybe a bit of marketing could improve the situation. Instead of useState maybe more appropriate name would be useVar? State implies a set of distinct
states with valid transitions between, while “var” doesn’t, it represents a bag for a value and that value may change. If we keep going, useReducer could be renamed to
useState or useStates (plural) to convey it use with state transitions.
It’s not ideal but we can do this today with import aliasing –
import { useReducer as useStates, useState as useVar } from 'react'
function SomeOtherComponent(props) {
let [ state, dispatch:send ] = useStates({ /* state description */ });
let [ value, setValue ] = useVar("Aviato");
}
Are useState clumps a huge problem? No, probably not. This was more a thought experiment than anything. But more often than not they’re a signal for what I can expect from the
remainder of the component and just how much has been invested in getting the data structures right.

Building a Better Star Icon
Don’t let the simple exterior fool you, this little star hides a plethora of decisions, trade-offs, tunings. But
most importantly, I think it’s more enojyable to use.
Creating the star shape
To start, I wanted to see how much I could implement using only HTML/CSS, no images or fancy fonts and no dependencies. So how could I
make the star shape if not using an image or font? I initially reached for the CSS polygon function. The idea being the points trace
out a path which you can then use with the CSS clip-path property. Those pixels of the element inside the polygon are visible, while
those outside are hidden.
clip-path: polygon(
50% 0%,
61% 35%,
98% 35%,
68% 57%,
79% 91%,
50% 70%,
21% 91%,
32% 57%,
2% 35%,
39% 35%
);
The major benefit of this approach is that being specified with percentages, the shape is automatically responsive (provided you
maintain correct aspect ratio of the containing element).
The drawback however, is that you can’t do rounded corners; the browser connects points by straight lines. In order to stay consistent
with the original icon I needed rounded corners. If I wasn’t concerned with having the icon resizable I could have used the path
function and called it a day. But in order to have a resizable and rounded star, I would need to define the shape within an SVG.
<svg
width={0} (a)
height={0}
style={{ position: "absolute" }}
className="icon-star"
viewBox="0 0 1 1" (b)
>
<defs>
<path
id="star"
d="M0.4351 0.0426 C0.4571 -0.0142 0.5374 -0.0142 0.5594 0.0426 L0.6428 0.2576 C0.6524 0.282 0.6751 0.2986 0.7012 0.3001 L0.9315 0.3129 C0.9923 0.3163 1.0171 0.3927 0.9699 0.4311 L0.7912 0.5768 C0.7709 0.5934 0.7621 0.6202 0.7688 0.6456 L0.8278 0.8685 C0.8434 0.9274 0.7784 0.9746 0.7272 0.9416 L0.5334 0.8166 C0.5114 0.8024 0.4831 0.8024 0.4611 0.8166 L0.2673 0.9416 C0.2162 0.9746 0.1512 0.9274 0.1668 0.8685 L0.2258 0.6456 C0.2325 0.6202 0.2238 0.5934 0.2035 0.5768 L0.0246 0.4311 C-0.0225 0.3927 0.0023 0.3163 0.0631 0.3129 L0.2934 0.3001 C0.3196 0.2986 0.3423 0.282 0.3519 0.2576 L0.4351 0.0426 Z"
/>
<clipPath id="star-clip" clipPathUnits="objectBoundingBox"> (c)
<use href="#star" />
</clipPath>
</defs>
</svg>
A couple things to note
- I need the SVG in the DOM but I don’t want to see it or have it participate in layout so I hide it (note: I can’t do
display: 'none'
as this removes it from the DOM).
- The
viewBox acts as the bridge between the host coordinate system (here, the browser) and the SVG. The value 0 0 1 1 sets the
viewBox to 1x1. This combined with the fact that the path coordinates are normalised (between 0 and 1) means the path is described
using percentages relative to the containing element.
- We place the path inside a
defs element because we don’t want it to render - we want to access it via its id via CSS.
Let there be light..
With the path created, in order to see anything, we still need two things - an element…
return (
<>
<div className="root" onClick={toggleFavourite}>
<svg
width={0}
height={0}
style={{ position: "absolute" }}
className="icon-star"
viewBox="0 0 1 1"
>
<defs>
<path
id="star"
d="M0.4351 0.0426 C0.4571 -0.0142 0.5374 -0.0142 0.5594 0.0426 L0.6428 0.2576 C0.6524 0.282 0.6751 0.2986 0.7012 0.3001 L0.9315 0.3129 C0.9923 0.3163 1.0171 0.3927 0.9699 0.4311 L0.7912 0.5768 C0.7709 0.5934 0.7621 0.6202 0.7688 0.6456 L0.8278 0.8685 C0.8434 0.9274 0.7784 0.9746 0.7272 0.9416 L0.5334 0.8166 C0.5114 0.8024 0.4831 0.8024 0.4611 0.8166 L0.2673 0.9416 C0.2162 0.9746 0.1512 0.9274 0.1668 0.8685 L0.2258 0.6456 C0.2325 0.6202 0.2238 0.5934 0.2035 0.5768 L0.0246 0.4311 C-0.0225 0.3927 0.0023 0.3163 0.0631 0.3129 L0.2934 0.3001 C0.3196 0.2986 0.3423 0.282 0.3519 0.2576 L0.4351 0.0426 Z"
/>
<clipPath id="star-clip" clipPathUnits="objectBoundingBox">
<use href="#star" />
</clipPath>
</defs>
</svg>
{/* Element which will use the above shape as a clip-path */}
<div className={classes.shape}></div> (a)
</div>
</>
);
…and some CSS.
.shape {
width: 100%;
height: 100%;
clip-path: url("#star-clip");
background-color: var(--colour-default);
}
The clip-path: url("#star-clip") is the star of the show (baddum-tisch). If you’re not familiar, clipping is the technique
of demarcating an image, creating two regions: inside the region things are visible; outside the region they’re not. In this case the
path specified by the id “star-clip” specifies the visible shape (our rounded star) and everything beyond that to the edges of the
border box is not shown.
Adding some interaction
So now we have a star with nicely rounded corners. But what about interaction? That was my initial gripe - there was no hover or
transition between states. I wasn’t exactly sure what would do the job so I prototyped a few. First I tried animating the background
like a cup filling up; on hover it would “fill” a little, and then when clicked it would animate to the top. The problem with
this was the icon size. It was going to be used at a height between 17-21 pixels. This meant when hovered the cursor actually obscured
a lot of it and you couldn’t even see the inital animation. I also tried rotation, but even a quarter turn felt too heavy-handed, and also
moving such a small target whilst hovering is never a great experience.
I ultimately landed on a radial fill which grows outward from the center.
.fill {
position: absolute;
border-radius: 50%;
width: 0px;
height: 0px;
opacity: 0;
background-color: var(--colour-favourited);
pointer-events: none;
&.is-favourite {
animation: 0.3s ease-out forwards explode;
}
}
@keyframes explode {
100% {
/* This needs to be big enough that it grows */
width: 105%;
height: 105%;
opacity: 1;
}
}
It think it strikes a nice balance between dynamism and subtelty. After all, this component will be located in a table of potentially
hundreds of rows where it could be used 5, 10, 20 times at once - having the star spin for every hover and/or click would quickly
get overwhelming.
return (
<>
<div className="root" onClick={toggleFavourite}>
{/* svg elided for brevity */}
{/* This element controls the overall shape of the icon */}
<div className={classes.shape}>
{/* This element handles the interaction animations */}
<div className={classes.fill}></div>
</div>
</div>
</>
);
The crux of this effect works because the inner element is also constrained by the parent’s clip-path property.
Wrap up
Lastly, I found having the click/hover on the star shape finicky and annoying. It was too easy to miss the shape, which made it frustrating
to click. I needed a bigger more uniform hit-area so I changed the hover/click to the root element and added a little padding. What resulted
was an app-like icon which everyone knows and loves. A tiny detail and obvious in hindsight but easy to miss when you’re head is in the weeds
of CSS easings and SVG view boxes.
It’s easy to go overboard when developing a component in isolation. There’s none of the final setting influencing your decisions
and you can easily forget the final setting it will operate it, what size it will be and just what type of flow or operation it will be
used in. By contrast, developing it in-situ it would be hard to escape the context of the elements around it and how busy a screen may
already be. What took me half an hour to realise that an animation en-masse would be overwhelming might have been apparent within 5secs
had I a realistic environment to develop in.
This is the full source, JSX and CSS.
import { useState } from "react";
import "./Star.css";
const Star = ({ dimension }) => {
let [favourite, setFavourite] = useState(false);
let classes = {
shape: (favourite ? ["shape", "is-favourite"] : ["shape"]).join(" "),
fill: (favourite ? ["fill", "is-favourite"] : ["fill"]).join(" "),
};
const toggleFavourite = () => {
setFavourite((favourite) => !favourite);
};
return (
<>
<div className="root" onClick={toggleFavourite}>
<svg
width={0}
height={0}
style={{ position: "absolute" }}
className="icon-star"
viewBox="0 0 1 1"
>
<defs>
<path
id="star"
d="M0.4351 0.0426 C0.4571 -0.0142 0.5374 -0.0142 0.5594 0.0426 L0.6428 0.2576 C0.6524 0.282 0.6751 0.2986 0.7012 0.3001 L0.9315 0.3129 C0.9923 0.3163 1.0171 0.3927 0.9699 0.4311 L0.7912 0.5768 C0.7709 0.5934 0.7621 0.6202 0.7688 0.6456 L0.8278 0.8685 C0.8434 0.9274 0.7784 0.9746 0.7272 0.9416 L0.5334 0.8166 C0.5114 0.8024 0.4831 0.8024 0.4611 0.8166 L0.2673 0.9416 C0.2162 0.9746 0.1512 0.9274 0.1668 0.8685 L0.2258 0.6456 C0.2325 0.6202 0.2238 0.5934 0.2035 0.5768 L0.0246 0.4311 C-0.0225 0.3927 0.0023 0.3163 0.0631 0.3129 L0.2934 0.3001 C0.3196 0.2986 0.3423 0.282 0.3519 0.2576 L0.4351 0.0426 Z"
/>
<clipPath id="star-clip" clipPathUnits="objectBoundingBox">
<use href="#star" />
</clipPath>
</defs>
</svg>
<div className={classes.shape}>
<div className={classes.fill}></div>
</div>
</div>
</>
);
};
export default Star;
/* ----------------------------------------------
Defines an app icon-like shape, most of which
is transparent, but serves to increase the
hit-area of the icon making it easier to hit
a nod to Fitt's Law
------------------------------------------- */
.root {
--colour-default: oklch(0.95 0.005 91);
--colour-favourited: oklch(0.85 0.15 91);
--dimension: 35px;
position: relative;
width: var(--dimension);
height: var(--dimension);
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
border-radius: 9px;
padding: 6px;
overflow: hidden;
}
/* ----------------------------------------------
Defines the star shape based on a SVG embedded
------------------------------------------- */
.shape {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
clip-path: url("#star-clip");
background-color: var(--colour-default);
transform-origin: center;
transition:
scale 0.2s ease-in-out,
background-color 0.2s linear;
&.is-favourite {
animation: 0.225s ease-out bump;
}
&:is(:hover, :active) {
cursor: pointer;
background-color: oklch(from var(--colour-default) l 0.05 h);
}
}
@keyframes bump {
50% {
scale: 1.1;
}
100% {
scale: 1;
}
}
/* ----------------------------------------------
The background colour for the star when it's
in the "selected" state. Adds a "bump"
interaction animation which provides more
feedback that the click occurred
------------------------------------------- */
.fill {
position: absolute;
border-radius: 50%;
width: 0px;
height: 0px;
opacity: 0;
background-color: var(--colour-favourited);
pointer-events: none;
&.is-favourite {
animation: 0.3s ease-out forwards explode;
}
}
@keyframes explode {
100% {
width: 105%;
height: 105%;
opacity: 1;
}
}
