Part 1: Creating a Blue Print, setting up TDD and Test Specs to Be CERTAIN that Our Code is Built to Spec.
What we'll learn in this article
Hi everyone! ๐
Today, I'm going to share and analyze a pattern that I've become quite fond of recently for managing state management in a way that is truly domain agnostic.
We'll do this by first discussing a new pattern for implementing state management code using RxJS in this article. In a follow up article (that I've committed to releasing next friday), we'll demonstrate its "domain agnosticism" by using the same code to create a small app in vanilla JS [by which I simply mean "JS without a framework/library"], Angular, and React.
There's something of a prerequisite of some more basic JS knowledge, and familiarity with RxJS would helpful here, but my goal is for this to be accessible for junior and senior engineers alike!
Also before we proceed, [not-so-]coincidentally, I've recently published a new npm package to the registry that we'll be using in this series: ๐๐๐ @derxjs/view-model ๐๐๐.
Installing the package is not necessary for this article, but I'm very excited for our roadmap as I think that this had the potential to be a huge E = mc^2
moment (more on that later!), so I highly recommend that you check it out.
Here's how the work will break down:
First part we'll need to do is create TS types, which will serve as our blueprint for the lego blocks.
From there, it doesn't really matter which of the 5 second-tier tasks we take on next, but in some order, we're going to:
- create our ViewModel tests
- build out the ViewModel implementation
- create a "vanilla" JS app
- create an Angular app
- create a React app
In another world, we could even potentially have 5 different developers each working on these tasks in parallel if we had the resources for that!
Unfortunately, my brain (and articles in general) is for the most part only single threaded, [insert huberman labs link on double focus] so instead, our path through these tasks is going to look like this:
In this article, we'll create our @derxjs/view-model
TS types, then write out our tests, and then finally use TDD to implement our spec with absolute confidence that we built what we set out to build.
Next week, we'll put this all to good use, creating 3 webapps with 3 different frameworks.
So without further ado..... let's do this!!!
Establishing our tools
[This section is designed more for junior developers - please feel free to skip these if you're already familiar with Ts and Observables and RxJS]
Typescript
For this article, we'll be using Typescript interfaces to help us think about our problem, and to help us to architect a solution. In the context of our view model pattern, Typescript will allow us to create a "shape" of the view model data that our particular "domains" (think vanilla JS, Angular, and React) will leverage to create our html from that data (this is also known as "templating").
Think of TS as a blueprint that we'll use early on in our development process to lay out our problem, that we'll continue to leverage and keep us honest as we actually build our solution out.
RxJS
RxJS is a library we'll use to compose observable event streams (or Observable
s) in our code. I advise that it's best to think of Observable
s as "Timelines".
RxJS also introduces the idea of an "operator" as a function that takes an Observable
and returns a new Observable
. In the context of our "Observable
s as 'Timelines'" mental model then, an operator is a function that takes a "Timeline" as input, and returns a new "Timeline" as output.
(^ this is a talk I gave on Observable
s as "Timelines" at RxJS Live 2021.)
The โProโsโ of RxJS are:
- Extreme precision and control over code
- ability to write tests with a higher order of magnitude of precision
- Declarative, expressive, powerful code
- Huge potential for composability
- A perfect mental model and toolset for frontend web development (essentially a flurry of user-generated events with no guaranteed sequence)
The โConโsโ:
- Takes a long time to fully understand
- Even if YOU make the investment to learn it, hard to pass that buck off onto the next developer to use our code [makes it difficult to scale]
- Many complicated/confusing dimensions
- "Observable joining strategies" (merge, switch, exhaust, concat)
- Hot vs. Cold vs. Luke-Warm (cold observable sourced with a hot observable)
- Behavior vs. Replay vs. plain vs. "Async" emmission strategy
- โDangerousโ
- Risk of โleaky subscriptionsโ
- Risk of wrong implementation bugs
- Risk of poor implementation leading to even worse code (the only thing worse than complicated imperative code is a complicated mess of imperative code with a healthy chunk of โreactiveโ code mixed in)
- They flood the call stack, making debugging untennable (this is especially unfortunate as bugs in your RxJS code are probably the kind of bugs that we need good debugging tools for the MOST)
In many ways, the pattern recommended in this article (and supported on @derxjs packages) is designed to squeeze all the awesome benefits out of RxJS, while avoiding all the nasty pitfalls:
- Our view model function will take as input an
Observable
of our user's events - It will return as output a single
Observable
of ourViewModel
interface - We'll build out extensive, precise test suite BEFORE starting our implementation to ensure that we're developing EXACTLY what we set out to build.
- We'll use operators inside of our implementation to compose the transition of the input
Observable
s to the outputObservable
What is the "DeRxJS View Model" Pattern?
The basic idea for the "DeRxJS View Model" pattern is:
- Pass Observables in (along with maybe a few functions or pieces of data)
- Get a single Observable out
- Use your framework of choice for templating (turning the output Observable from #2 into html).
The E = mc^2
of State Management
import { Observable } from "rxjs";
export type DeRxJSViewModel<InputType, ViewModelType> = (
inputs: InputType
) => Observable<ViewModelType>;
^ The core of my @derxjs
approach are these 5 lines of Typescript code. It's deceptively simple, but my hypothesis is, this pattern is elegant enough to encompass any && all possible state management requirements. It's a powerful core that has lots of potential as a pattern and mental model - as well as potential tools and utility code we can build on top of it!
To use this type, simply install the package (npm i @derxjs/view-model
) and import the type into your code (import { DeRxJSViewModel } from '@derxjs/view-model';
) [don't worry, I'll show it in use in our actual code snippets as well!].
If you want to opt out though - you can always just copy these 5 lines of code, and be on your merry way :).
Introducing the problem: Tic Tac Toe
Our example problem for today: creating a Tic Tac Toe game.
Let's break this idea down to a rough spec in English:
Presentational concerns
- The user will need to see a 3x3 grid that will operate as our game board. We'll make this a 3x3 grid of buttons, and we'll need in our view model some way of modeling/structuring this data.
- We'll want to allow the user to reset the game at anypoint (even while the computer is "thinking"). This should empty the board and make it their turn.
Logical concerns
- We'll want to introduce the idea of "turns" to the game. During the user's turn, they should be allowed to click a button, but after clicking a button, they shouldn't be allowed to move until the computer opponent makes a move. We'll also want to make this feedback prominent to the user.
- We'll need some AI for our computer opponent. As cool as it would be to use some GPT-3 here, instead, let's opt for a Random Number Generator (RNG) to act in the place of our AI, and we'll simulate our AI thinking by adding in a delay of 2 seconds before the computer player makes their move.
- If the game is over (if either our player or the computer wins, or if the board is full and neither player won) we'll want to inform the user, and they'll need hit restart to play again.
Creating a Typescript blue print
Let's start by identifying the parameters of our system. The way I tend to think about this problem, the inputs come down to:
- the user's events selecting spaces on the board.
- the user's events selecting the "restart game" option.
- a function that will act as the ai that will define how our computer player will act.
We can visualize the first 2 of these as Timelines, so we'll represent them as Observables - for the selection of spaces by the user, we'll represent that event with an object that states the column and row the user selected, and for the reset button clicks, the contents of the event actually doesn't matter much (just the fact that it happened is really all we need!).
For our ai, we'll define it as some function that will take a given Board
and the computer's letter (x or o) and return the SpaceCoordinates
for the space the computer will choose. This way we can pass in a determinist ai
function for reliable testing of our code [but we'll see more on this later!!].
Let's transpose these to actual Typescript now:
interface TicTacToeViewModelParams {
userSpaceClickEvents$: Observable<SpaceCoordinates>;
userResetClickEvents$: Observable<void>;
ai: TicTacToeAi
}
interface SpaceCoordinates {
row: BoardIndex;
column: BoardIndex;
}
type BoardIndex = 0 | 1 | 2;
type TicTacToeAi = (params: AiParams) => SpaceCoordinates;
interface AiParams {
board: Board;
aiLetter: 'o' | 'x';
}
type Board = [
[SpaceContent, SpaceContent, SpaceContent],
[SpaceContent, SpaceContent, SpaceContent],
[SpaceContent, SpaceContent, SpaceContent],
];
type SpaceContent = 'x' | 'o' | '';
Next up, we'll create an interface for our View Model itself. We'll represent this with a board
property that should contain a 3x3 array of either x's, o's, or empty spaces. The other thing we'll need is a turn
property that will tell us if it's our turn, the computer's turn, or if the game is over.
interface TicTacToeViewModel {
board: Board;
turn: Turn;
}
type Turn =
| 'your turn'
| `computer's turn`
| 'game over - you win'
| 'game over - you lose'
| `game over - it's a tie`;
The last step is to combine these into a function that takes our inputs and returns an Observable
of our view model. Let's set out that function's signature now!
import { DeRxJSViewModel } from '@derxjs/view-model';
export const ticTacToeViewModel$: DeRxJSViewModel<
TicTacToeViewModelParams,
TicTacToeViewModel
> = ({
userSpaceClickEvents$,
userResetClickEvents$,
ai
}) => {
// implementation to go here
}
That about does it for our blueprint! Here's a rough blue print that matches our DeRxJS diagram from before:
As we mentioned prior, one of the best things architecturally about having done this work is we have 3 independent paths we could follow from here:
- implementing the function
- templating our component (in any of the 3 frameworks)
- writing tests for our function!
To reiterate: in another scenario, we could even potentially have 3 different developers each working on a different branch of these three things at once.
Since our brains are mostly single-threaded though, let's start with writing tests for our function, then implementing the function, then templating!
TDD and setting up our Timeline Tests for our View Model
For a sneak peak of this in real code, go check out the example .spec file in the @derxjs repo (maybe go give us a start there too if you feel like it! :))
So, let's break this concept down && use some cool visualizations in the process. Before we get into that, let's address one big hairy issue about our specific system: RNG.
Our spec requires that the ai choose a valid space at random. The AI being random immediately throws off a key component of a reliable test suite: being deterministic (or "the same thing happening EVERY time").
Luckily, we thought of this before designing our system :). Because the ai()
function is passed in as a parameter to our function, we can pass in a "deterministic AI" to our view model for our test, and then a "random AI" to our actual implementation (we could also come up with some other cool "flavors" of AI if we wanted - feel free to take this code and go nuts with it if you like! Would be cool to see someone come up with an "I always lose" AI.)
To get this, let's quickly draft out this ai function:
const testAiFunction: TicTacToeAi = ({ board }) => {
for (const i of [0, 1, 2] as const) {
for (const j of [0, 1, 2] as const) {
if (board[i][j] === '') {
return { row: i, column: j };
}
}
}
throw new Error('Ai was passed a full board. This should never happen');
};
We should see the following behavior from this AI:
Very cool. By leveraging functions (for the people who like terminology, this is technically a "higher order function" as the DeRxJSViewModel is a function that took this ai()
function as a parameter), we can see that we are able to pretty effortlessly make our system very highly configurable! If we wanted to - we could actually have passed in an async
function (or a function that returns a Promise
) here as well, and built in the 2 second delay right into our AI for even more configurability, but I think this is enough complexity for now.
Btw - if you thought passing functions was cool - just wait until what we can do when passing in observables as input!!
The Test Suite
We want to build out a test to cover our system. In general, we want to make sure we hit most of our edge cases and then maybe run the whole way through a couple times.
It's usually nice to start with a nice basic base-case, so let's start with an empty board where the user never makes any actions:
(Since we'll always use the same ai()
function for every test, I've skipped that in the diagrams) but as we can see, this very basic test shows that with no user events, we start out with an empty board, and that board never changes.
Here's the code for this exact scenario:
test('with no user events, the board stays empty', () => {
const firstUserState = createInitialViewModel();
testScheduler.run(({ cold, expectObservable }) => {
const userBoardClicks = cold<SpaceCoordinates>('------');
const userResetClicks = cold<void>('------');
const expected = cold('a-----', { a: firstUserState });
const result = ticTacToeViewModel$({
userSpaceClickEvents$: userBoardClicks,
userResetClickEvents$: userResetClicks,
ai: testAiFunction,
});
expectObservable(result).toEqual(expected);
});
});
Notice the ASCII art syntax!! While it's not bad here, I think it gets a bit of a bad rap for being a bit cumbersome to work with, and not without good reason! I was helping out with trying to line up these ASCII art kind of tests on the rxjs codebase, so I can vouch that it's not fun! (I think I got through 3 operators before I gave up :()
(@derxjs is working on a nice gui tool to "draw" out these timelines and spit out working jest .spec.ts files - but that's an article for another time)
Also notice the testScheduler
! We'll touch on this in the next example:
So in this example, we can see the player chooses the center square - note the dotted vertical line! This shows us how the timelines are aligned horizontally so that the moment we observe the user select the middle square, we should immediately observe a new state in our UI - reflecting an 'x' in that square and showing that it is now the 'computer's' turn to move. Then 2 seconds later (2 seconds of "virtual time" thanks to our TestScheduler!!) we should see our AI move to that first open slot in the top left corner, and it should now be our player's turn again.
So to recap: our timeline test is showing us that for the given inputs (the two observables/timelines) here's the expected timeline.
Remember when we discussed the expressive power of the ai()
function as a parameter to our tic-tac-toe system? These observable inputs are actually bringing in with them AN INFINITE NUMBER OF POTENTIAL FUTURES, EVENTS, AND COMBINATIONS OF EVENTS
and our viewModel function is [attempting] to WRANGLE EVERY SINGLE LAST ONE of them and handle them appropriately, and RxJS is masterful at allowing us to do so.
Part of the reason I'm so long on these timeline tests are because of how good they are at setting a mental model - and especially with RxJS code being so difficult to debug, these marble tests become even MORE valuable! After all, you don't have to debug what ain't broke!
Here's the code for this test (notice things starting to get a bit more hairy!
test('computer player makes a move 1999ms after the player selects a square', () => {
const initialState = createInitialViewModel();
const userClick: SpaceCoordinates = {
row: 1,
column: 1,
};
const afterUserClick: TicTacToeViewModel = {
board: [
['', '', ''],
['', 'x', ''],
['', '', ''],
],
turn: `computer's turn`,
};
const afterComputerMoves: TicTacToeViewModel = {
board: [
['o', '', ''],
['', 'x', ''],
['', '', ''],
],
turn: `your turn`,
};
testScheduler.run(({ cold, expectObservable }) => {
const userBoardClicks = cold<SpaceCoordinates>('---a----', {
a: userClick,
});
const userResetClicks = cold<void>('------');
const expected = cold('a--b 1999ms c', {
a: initialState,
b: afterUserClick,
c: afterComputerMoves,
});
const result = ticTacToeViewModel$({
userSpaceClickEvents$: userBoardClicks,
userResetClickEvents$: userResetClicks,
ai: testAiFunction,
});
expectObservable(result).toEqual(expected);
});
});
Let's move onto the next case: make sure that nothing happens when a player clicks on an already filled square (and also that nothing happens 2 seconds AFTER the user clicks that filled square.... for hopefully evident reasons!!)
code:
test('user click does nothing on an already filled square', () => {
const initialState = createInitialViewModel();
const userClicksCenterSquare: SpaceCoordinates = {
row: 1,
column: 1,
};
const afterUserClick: TicTacToeViewModel = {
board: [
['', '', ''],
['', 'x', ''],
['', '', ''],
],
turn: `computer's turn`,
};
const afterComputerMoves: TicTacToeViewModel = {
board: [
['o', '', ''],
['', 'x', ''],
['', '', ''],
],
turn: `your turn`,
};
const userClicksComputersSquare: SpaceCoordinates = {
row: 0,
column: 0,
};
testScheduler.run(({ cold, expectObservable }) => {
const userBoardClicks = cold<SpaceCoordinates>('---a 3s a-b-aaaa-bbbb-', {
a: userClicksCenterSquare,
b: userClicksComputersSquare,
});
const userResetClicks = cold<void>('------');
const expected = cold('a--b 1999ms c', {
a: initialState,
b: afterUserClick,
c: afterComputerMoves,
});
const result = ticTacToeViewModel$({
userSpaceClickEvents$: userBoardClicks,
userResetClickEvents$: userResetClicks,
ai: testAiFunction,
});
expectObservable(result).toEqual(expected);
});
});
Notice our output looks the same, even though our user is spamming those selected squares!
Next up: The reset button works
test('reset button works', () => {
const initialState = createInitialViewModel();
const userClicksCenterSquare: SpaceCoordinates = {
row: 1,
column: 1,
};
const afterUserClick: TicTacToeViewModel = {
board: [
['', '', ''],
['', 'x', ''],
['', '', ''],
],
turn: `computer's turn`,
};
const afterComputerMoves: TicTacToeViewModel = {
board: [
['o', '', ''],
['', 'x', ''],
['', '', ''],
],
turn: `your turn`,
};
testScheduler.run(({ cold, expectObservable }) => {
const userBoardClicks = cold<SpaceCoordinates>('---a------', {
a: userClicksCenterSquare,
});
const userResetClicks = cold<void>('---- 1999ms ---a', { a: undefined });
const expected = cold('a--b 1999ms c--a', {
a: initialState,
b: afterUserClick,
c: afterComputerMoves,
});
const result = ticTacToeViewModel$({
userSpaceClickEvents$: userBoardClicks,
userResetClickEvents$: userResetClicks,
ai: testAiFunction,
});
expectObservable(result).toEqual(expected);
});
});
Fairly straight-forward - but it will be a good marker to let us know if the most basic functionality of resetting our game is working properly
Next one is around the same reset case, with a twist: the user can reset the game while the computer is "thinking" (making sure that the move the computer would have made is appropriately "cancelled"!)
I think that covers it for most of the potential edge cases of our system! For good measure, I'd also throw a couple fully-played out games as well just to be sure that we can make it all the way through a game without issues (I'm not gonna diagram those out here, but you can see it in action for yourself in the @derxjs codebase :))
Run the test suite now if you like:
git clone https://github.com/ZackDeRose/derxjs.git
cd derxjs
npm i
npx nx test examples-tic-tac-toe-view-model-implementation
Implementing our "DeRxJS View Model" Pattern
Alright, so we've got a fully fleshed out spec at this point - time to implement!
(Btw - if you want to give yourself a fun challenge now - go clone my derxjs repo and erase the contents of tic-tac-tow-view-model-implementation/src/lib/view-model.spec
, and see if you can get the tests to pass before reading this section!)
We could honestly go a couple ways with this specific problem (in particular a 'state machine' approach would be a great fit for this - and I hope to do a proper XState implementation [maybe even a @derxjs/xstate package somewhere down the line!!] at some point in the future).
But because it's a pretty common pattern, we'll look at a "reducer" solution here!
If you're familiar with the JavaScript Array.reduce() method, that's a great place to start:
const foo = [1,2,3,4]
function sum(arr: number[]): number {
return arr.reduce(
(accumulator, arrayItem) => accumulator + arrayItem,
0
);
}
console.log(sum(foo)); // 10
In this code, we used reduce
to "reduce the contents of the array down to a single value: their sum." Notice that this too is a higher-order function (since it takes a function as a parameter), making it very versatile. You can do ALOT of things with reduce.
We're actually interested more in the reducer function though, specifically:
(accumulator, arrayItem) => accumulator + arrayItem
The idea for this function is that it will loop through the list and call this function for each item; the item itself going into the arrayItem
param, and the return value of the prior function call going into the accumulator
param.
In this way, we "reduce the array down to a sum"! Pretty cool.
We're going to take this same concept a bit further for our tic-tac-toe example now, expanding the anaology in 2 separate dimensions:
- Instead of numbers, we're going to reduce some set of "observed events" (think user clicks and ai moves) down into the
TicTacToeViewModel
interface from the prior section; here it is again if you missed it:
export interface TicTacToeViewModel {
board: Board;
turn: Turn;
}
export type Turn =
| 'your turn'
| `computer's turn`
| 'game over - you win'
| 'game over - you lose'
| `game over - it's a tie`;
export type SpaceContent = 'x' | 'o' | '';
export type Board = [
[SpaceContent, SpaceContent, SpaceContent],
[SpaceContent, SpaceContent, SpaceContent],
[SpaceContent, SpaceContent, SpaceContent]
];
- Instead of synchronously iterating through a defined list and returning a value at the end of it all, we're going to asynchronously handle events immediately as they are observed, emitting a corresponding state. (this one sounds intimidating, but this one is actually quite trivial thanks to RxJS!)
Before getting overwhelmed in all this, let's take a deep breath and break this down into it's pieces. The first step is to identify the events that could potentially change our TicTacToeViewModel
.
Two of them are pretty clear (because they're input observables to our function!): user clicking a square, and user clicking reset.
Let's go ahead and wrap those up now, by creating interfaces for them that wrap their value in an object and add a 'type' property to them (we'll switch on these 'type values later on!):
interface UserSpaceClickAction {
type: 'user space click';
space: SpaceCoordinates;
}
// export interface SpaceCoordinates {
// row: BoardIndex;
// column: BoardIndex;
// }
interface UserResetAction {
type: 'user reset click';
}
[You might see this elsewhere referred to as "tokenizing" (or "actionizing") the data.]
There's one more event that would change our view model though: the ai's moves:
interface AiAction {
type: 'ai action';
space: SpaceCoordinates;
}
We'll create a union type of these actions via TS as well:
type Action = UserSpaceClickAction | UserResetAction | AiAction;
Think of this as "grouping" the actions into a single type, where you know an Action
is one of the 3 possible.
Now we'll write the signature for our reducer
function:
export function reducer(
state: TicTacToeViewModel,
action: Action
): TicTacToeViewModel {
// ... impl here
}
Best to think of this function as "handling" an action - so if a user clicks on a button, the next state should be the same as before, but with an 'X' on the square the user selected (and also the computer's turn - or maybe the user even won with that move and we should say "You Win!!"):
Or if the user clicks "reset" we should get a new board:
Or if the user clicks on an occupied square, or tries to act while it's the computer's turn, we should just stay the same.
Hopefully these sound familiar, because these are pretty much exactly the test cases we had tested prior! ONLY! Rather than thinking about the whole flow of data (the whole timeline) instead we're focused in on the micro: handling a single action.
Before we actually write these functions though, let's create some utility functions for the things we'll need:
TicTacTo Utility Fns
function isSpaceOccupied(board: Board, space: SpaceCoordinates): boolean {
return board[space.row][space.column] !== '';
}
/**
* assumes the given `space` is unoccupied!
*/
function nextBoard(
board: Board,
space: SpaceCoordinates,
player: 'x' | 'o'
): Board {
const newBoard: Board = [[...board[0]], [...board[1]], [...board[2]]];
newBoard[space.row][space.column] = player;
return newBoard;
}
function isGameOver(
board: Board
): 'user wins' | 'computer wins' | 'tie game' | false {
const winningCombinations: [
SpaceCoordinates,
SpaceCoordinates,
SpaceCoordinates
][] = [
[
{ row: 0, column: 0 },
{ row: 0, column: 1 },
{ row: 0, column: 2 },
],
[
{ row: 1, column: 0 },
{ row: 1, column: 1 },
{ row: 1, column: 2 },
],
[
{ row: 2, column: 0 },
{ row: 2, column: 1 },
{ row: 2, column: 2 },
],
[
{ row: 0, column: 0 },
{ row: 1, column: 0 },
{ row: 2, column: 0 },
],
[
{ row: 0, column: 1 },
{ row: 1, column: 1 },
{ row: 2, column: 1 },
],
[
{ row: 0, column: 2 },
{ row: 1, column: 2 },
{ row: 2, column: 2 },
],
[
{ row: 0, column: 0 },
{ row: 1, column: 1 },
{ row: 2, column: 2 },
],
[
{ row: 0, column: 2 },
{ row: 1, column: 1 },
{ row: 2, column: 0 },
],
];
for (const [x, y, z] of winningCombinations) {
if (
board[x.row][x.column] === 'x' &&
board[y.row][y.column] === 'x' &&
board[z.row][z.column] === 'x'
) {
return 'user wins';
}
if (
board[x.row][x.column] === 'o' &&
board[y.row][y.column] === 'o' &&
board[z.row][z.column] === 'o'
) {
return 'computer wins';
}
}
return (board.flat() as SpaceContent[]).some((contents) => contents === '')
? false
: 'tie game';
}
export function createInitialViewModel(): TicTacToeViewModel {
return {
board: [
['', '', ''],
['', '', ''],
['', '', ''],
],
turn: 'your turn',
};
}
Functions make for really great "Lego blocks" and now that we have all these, let's write out function (throwing the real implementation in here because too many cases to cover them all in text, but good for you to look over it and make sure it makes sense):
export function reducer(
state: TicTacToeViewModel,
action: Action
): TicTacToeViewModel {
switch (action.type) {
case 'user reset click': {
return createInitialViewModel();
}
case 'user space click': {
if (state.turn !== 'your turn') {
return state;
}
if (isSpaceOccupied(state.board, action.space)) {
return state;
}
const newBoard = nextBoard(state.board, action.space, 'x');
const result = isGameOver(newBoard);
switch (result) {
case false: {
return {
board: newBoard,
turn: `computer's turn`,
};
}
case 'user wins': {
return {
board: newBoard,
turn: 'game over - you win',
};
}
case 'tie game': {
return {
board: newBoard,
turn: `game over - it's a tie`,
};
}
default: {
throw 'should not be reached';
}
}
}
case 'ai action': {
const newBoard = nextBoard(state.board, action.space, 'o');
const result = isGameOver(newBoard);
switch (result) {
case false: {
return {
board: newBoard,
turn: 'your turn',
};
}
case 'computer wins': {
return {
board: newBoard,
turn: 'game over - you lose',
};
}
default: {
throw 'should not be reached';
}
}
}
}
}
Awesome!! It wouldn't be a terrible idea now to write some test for this reducer function (or even writing them before we implemented this) but since we already have our test suite set up, this is not really necessary. (Even the fact that we're using a reducer
strategy right now is implementation detail that we don't necessarily want to preserve moving forward!)
The majority of our work is done now! For the sake of time, I'm going to reveal the rest of the black box of our "reducer" implementation of the ticTacToeViewModel$()
:
The shape "engine" is actually quite generic to the reducer pattern (although passing in the ai()
function is a little bit of a twist... but not that much). If you've worked with NgRx, Redux, or some similar library, it actually probably looks very familiar already!
This "genericism" actually makes it quite nice to "package" this flow and... maybe publish it on npm??? (you can check out an example implementation for this same problem [that uses this exact same reducer function we just wrote!!] in the derxjs repo!)
But in any case, let's follow the diagram and see how to translate this into code:
First off, we'll take our incoming user actions and use the map operator to "tokenize" or wrap them into the right Action
type, and then merge them down to create a single actions$
Observable. We'll go ahead here and create out actionsSubject
and start sharing our actions to that subject.
const actionsSubject = new Subject<Action>();
const userClickActions$ = userSpaceClickEvents$.pipe(
map(
(space): UserSpaceClickAction => ({
type: 'user space click',
space: space,
})
)
);
const userResetActions$ = userResetClickEvents$.pipe(
map(
(): UserResetAction => ({
type: 'user reset click',
})
)
);
const actions$ = merge(userClickActions$, userResetActions$).pipe(
share({ connector: () => actionsSubject })
);
(Green shows what we've done!)
Next up, we're gonna hook that actions stream up with our reducer function to create our state observable. This is the Observable<TicTacToeViewModel>
that we set our to make!!! But we're not done yet...
const state$ = actions$.pipe(
scan(reducer, createInitialViewModel()),
startWith<TicTacToeViewModel>(createInitialViewModel()),
distinctUntilChanged()
);
^ Note how we used scan with our reducer function here. What we're doing is feeding in all the user's actions one-by-one, and each action is running through that reducer to get to a next "TicTacToeViewModel" object, which is a super clean && easy way to get to our Observable
of that TicTacToeViewModel
!!! This is where RxJS really shines in my opinion!
Note we're also starting with an initialState (think just an empty board) and we're going to use distinctUntilChanged to filter out any times our state observable would emit the same state over again (since we're creating this view model for a ui - it really doesn't make sense to make the UI re-render if [for example] the user tried to click on a space that was already occupied!
If we ran this now as is though, after the user made their first move, it would become the computer's turn, and so far, we haven't hooked up the ai piece of our code yet, so the user would just be stuck in that state....
This is where "effects" come into play! The idea of an effect (thinking generically in the context of DeRxJS/reducer) is that it is a function that takes our actions$
Observable, and our state$
Observable (or just one of them.... or NEITHER!) and returns a new Observable of actions that we're going to connect BACK into our actions Subject to share those actions back into the actions$ observable!
That's generically though - for our specific example, we only have the one effect that we need to worry about, and that's our AI!
We'll start this with the observed states, filtered down to only states where the turn has just changed to "computer's turn" - we'll then delay for 2 seconds before mapping the Board
that came with the state through our ai()
function to get our computer's move!
Last step is to wrap alll that into a (switchMap)[https://rxjs.dev/api/operators/switchMap] from the user reset actions! We do this so that if the user ever resets the game while the computer is "thinking", we'd "discard" that subscription, so that the computer doesn't randomly barge in 2 seconds after the last "user space click" action when it should be the user's turn!
const aiActions$ = userResetActions$.pipe(
startWith(undefined),
switchMap(() =>
state$.pipe(
filter((x) => x.turn === `computer's turn`),
delay(2000),
map((state) => ({
type: 'ai action' as const,
space: ai({ board: state.board, aiLetter: 'o' }),
})),
share({ connector: () => actionsSubject })
)
)
);
aiActions$.subscribe();
return state$;
Last thing to do, subscribe to our aiActions$
and return the state$
!
And yes - I'm aware there's a potentially leaky subscription there on the aiActions$!
I'm gonna say that's okay for now (in my mind, this "game" is all our app is, so no need to teardown...) - but be sure to checkout the DeRxJS/reducer source code to see how we could handle that!
Closing Thoughts
I'm very excited for the work we're doing here (and not just because of the upcoming DeRxJS roadmap ๐):
The reason I'm excited is that (in case you didn't notice) NONE OF THE CODE WE WROTE IN THIS ARTICLE is domain-specific. Beyond just Angular/React/Vue/etc., we could take this same 'tic-tac-toe' state management code now and drop it in a mobile app/a native app/Artificial Reality/Augmented Reality/really ANY presentation layer and our state management code would be valid, and proven to be to spec!
I'm very excited to see where this takes us next - and I can't wait to see y'all in the next article!!
About the author
Zack DeRose [or DeRxJS if you like] is:
- a GDE in Angular
- a recent nx conf/NgConf/RxJS Live/The Angular Show/ZDS speaker
- Creator of the @derxjs OSS packages
- Senior Engineer and Engineering Manager at Nrwl
Checkout out my personal website for more of my dev content! And go bug Jeff Cross/Joe Johnson if you want to hire me to come help out your codebase or come help level up your team on Nx/NgRx/DeRxJS/RxJS/State Management! (I especially love building awesome stuff - and building up teams with bright developers that are eager to learn!)
Top comments (0)