It has been more than a month since the previous blog post, despite feeling like it was posted last week. Time sure flies.
Last time the topic was about component librarie; they certainly help with getting a uniform look-and-feel for any application. But having an application that has a consistent design isn't particularly useful if none of the controls (such as buttons, sliders, and so on) do anything. This brings us to the topic of this post: managing state and reacting to changes.
In TypeScript there are a few "primitives" which can be used to model your state.
string: Represents text. A card's name or effect could be represented this way. Doesn't inherently represent any
number: A numeric value. A card's level could be represented this way, as well as the horizontal and vertical position at which an image should be drawn
Object: Group of "primitives" that can be accessed under a specific name. Useful for having closely related data together, such as all properties of a single card, the width and height of a rectangle, X and Y component of a coordinate, and so on.
Array: List of things in a specific order. Typically similarly typed content is put in an array (such as all different cards in the project).
A relatively simple representation of a card could be as follows:
{
"name": "Dark Magician",
"template": "NORMAL",
"attribute": "DARK",
"level": 7,
"ATK": "2500",
"DEF": "2100",
"type": ["Spellcaster"],
"effect": "The ultimate wizard in terms of attack and defense.",
}
A few of the standard traits of a Yu-Gi-Oh! cards have been represented in that block of text. Modifying those properties from code is easy enenough; simply reassign one of the values.
const card = { /* All of the above properites */ }
const nameEditor = document.getElementById("nameEditor");
nameEditor.addEventListener("change", function(evt){
card.name = evt.currentTarget.value;
});
If one were to type some text in the "nameEditor" textfield, it would update the card's name. But is the rest of the application aware of the change that has been made? The above code does not notify that the name property has been changed, so other code that might have something to do with that property (such as the renderer that creates an image from it) might not act on the change.
True, the code could constantly be rerun so the lastest values are used, but this can be a bit pointless if nothing has been changed. It is however not entirely without merit; games tend to work this way. An alternative is to invoke a function notifying others that changes have been made. This could be a tad repetive, and is easy to forget if the code is not structured in a way that forces you to do so.
Irrespective of how other parts of the code are notified about changes in the state, it needs to somehow figure out what has changed and how to respond to that, or it could start from scratch working from the new state. The former is rather finicky and error prone, the latter leads to a lot of extra work.
React (detailed in the previous post) takes an approach somewhere in between. Rather than making changes in place, you create a new immutable object that represents the state and notify the application about that new object. The immutable nature allows you to reuse components from the previous state; they will not be changing after all. This makes figuring out what elements need to be updated a whole lot easier: is this part of the state the same as the previous time? Yes: leave everything as is. No: Start from "scratch" (nested components might not need to be updated however).
var card = { name: "Dark Magician", level: 7 };
// Create a new object ({}), copy all properties from the previous state to it (card),
// and overwrite any of them with the newly specified values (level: 7).
card = Object.assign({}, card, { level: 8 }); // Output: { name: "Dark Magician", level: 8 }
Assigning a new value has become a tad more unwieldy, but we have created a completely new object, rahter than having changed something in place. The above might not work correctly with deeply nested properties, but there are utilites for that available, such as Immer.js.
Leaving the issues of responding to updates aside for a bit, there is another thing with the state that needs to be addressed: maintaining integrity. In the above example a new value gets created where the level is overwritten. In this case it is an acceptable level, but in the more general case there might need to be checks in place making sure it falls between zero and twelve (inclusive)… or make that thirteen since "Number iC1000: Numeronious Numeronia" exists.
Placing the logic in the event handler could work, but could become clunky if multiple sources were to work on same value. This could happen when a slider with a text field both control the value. Extracting the logic into its own function could help.
function UpdateLevel(card, level) {
if (0 <= level && level <= 13) {
return Object.assign({}, card, { level });
}
return card; // Invalid level; leave it unchanged?
}
// Somewhere in an event handler
setState(UpdateLevel(card, evt.target.value));
In this case it is only one value that needs to be updated, but what if some more things were related? Say… changing the name should also update the effect so any references to the card's "old" name now reference its new name. This could of course be added to the function that updates the name:
function UpdateName(card, newName) {
const newEffect = card.effect.replaceAll(card.name, newName);
return Object.assign({}, card, { name: newName, effect: newEffect });
}
That works… but what about the pendulum effect? It can simply be added. It is however a solution that does not scale well. In the actual game various cards refer to other cards by name (i.e. Fusion Materials). Having the "UpdateName" function run over all cards in the project to update any effects that refers to its old name quickly becomes unwieldy.
It would be more convenient if we could tell the state that we intend to update the name of a particular card, and have related state figure out for itself if it needs to update as well. This, to my knowledge is kind of the idea behind Flux/Redux.
import { createAction, createReducer, combineReducers } from "@reduxjs/toolkit";
const rename = createAction("card/rename", function(card, newName) {
// When every instance is informed about a name change, it might be a good
// idea to add a field indicating the intended target to prevent all cards
// from taking on the same new name. Hence "id".
return { payload: newName, meta: { id: card.id, oldName: card.name} };
});
const name = createReducer("Einzelgänger", function(builder) {
builder.addMatcher(rename.match, function(state, action) {
return action.payload;
})
});
const effect = createReducer("You can only control 1 \"Einzelgänger\".", function(builder) {
builder.addMatcher(rename.match, function(state, action) {
// Not a great implementation, but it should suffice for a demonstation.
return state.replaceAll(action.meta.oldName, action.payload);
});
});
const card = combineReducers({
name,
effect,
// …
});
In order to rename a card you would then dispatch the "rename" action from your event handler.
dispatch(rename(card, evt.target.value));
The state then decides how it should respond to that action, without the event handler needing to worry about any of the other details about how the state should be changed. Isn't that wonderful? Another advantage of using this method of working with state is that you can test everything in isolation before integrating it with other code to make sure individual parts work correctly.
Something worth noting, is that this example might be a bit convoluted. It is probably a better idea to have a special syntax where any references get resolved upon rendering the card. This to avoid all letters 'M' getting replaced with "Mokey Mokey" when typing that name after writing its effect first, or affecting other cards with the same name.
That should be enough for this post. Not sure which topic to address next, but there are enough topics to cover. Till next time!