Back in the summer of 2020 I wrote an article series on how to make your Hyperapp-apps modular. The ideas & concepts discussed are still valid, but the method was cumbersome and my explanation dense. I've since refined how I componentize apps, so it's time for an update!
Domains of the Counter Game
Here's a game. It goes like this. Click the plus and minus buttons to increase and decrease a value. When it reaches ten the game is over. You score one point for each button-click. Give it a try!
Yes, it's the dumbest game ever made. But it's a simple example of an app having some distinct domains – parts that make sense to think about in isolation from the rest.
Take the counter for example. You could imagine replacing the counter for a different (more interesting!) game while the scoring system, and the flow of screens from "play a game?" to "game over", could remain the same.
Implementations designed to make removing/replacing domains easy tend to be very maintainable. So let's see how maintainable the implementation is.
Domains have their own State
Have a look in the JS
tab above. We find that replacing is the counter for something else isn't exactly easy, because all the domains and their interactions are tangled up in a compact set of actions.
What we want is that actions belonging to a certain domain, should only affect the state of that domain. Take the Decr
action for example:
const Decr = (state) =>
state.mode !== "play"
? state
: {
...state,
counter: state.counter - 1,
score: state.score + 1
};
Decr
is for decrementing the counter, so it belongs to the counter domain. It should only affect the counter state:
const Decr = state => ({ ...state, counter: state.counter - 1 })
But then what about scoring points? That belongs to the score domain, so there should be a separate action for that:
const ScorePoint = state => ({ ...state, score: state.score + 1 })
But Decr
still needs to make ScorePoint
happen. To do that, we add an in-line effect in Decr
which dispatches ScorePoint
:
const Decr = state => [
{ ...state, counter: state.counter - 1 },
dispatch => dispatch(ScorePoint)
]
Effects for dispatching other actions
Using Hyperapp's effect system this way, to only dispatch another action, might seem like a hack (and maybe it is?). I think it makes sense, and here's why:
Imagine your app logic as a circuit board. There are points where you connect inputs such as sensors, buttons, et.c. Pressing a button connected to a certain point, is analogous to dispatching a certain action. Also, there are points where you send outgoing signals to activate whatever is connected – analogous to effects.
Let's say that instead of building your own circuit board from scratch, you source several smaller circuit-boards that do the various things you need, and hook them up. That means some output-connectors (effects) will need to signal (dispatch) some input connectors (actions) on other boards.
Dividing actions by Domains
Let's keep doing this to untangle the actions from each other.
The Incr
action of the counter can be treated in the same way we changed Decr
, but also we need to end the game once the value becomes 10:
const Incr = state => [
{ ...state, counter: state.counter + 1 },
dispatch => dispatch(ScorePoint),
state.counter === 9 && (dispatch => dispatch(EndGame)),
]
Of course we need to implement the EndGame
action, for affecting the mode state – another domain:
const EndGame = state => ({ ...state, mode: 'finish' })
The Play
action:
const Play = state => ({
mode: "play",
counter: 0,
score: 0
})
... also belongs to the mode domain. It represents the game starting, so it also needs to make sure to initialize the score and counter:
const Play = state => [
{...state, mode: 'play'},
dispatch => {
dispatch(InitScore)
dispatch(InitCounter)
}
]
And now those actions need to be defined as well.
const InitScore = state => ({...state, score: 0})
const InitCounter = state => ({...state, counter: 0})
Now each of the three domains – mode, score and counter – each have a set of actions for managing their domain's state with full sovereignty.
A Counter Component
Our goal is to be able to change stuff in one domain, without breaking anything outside it. So let's start with the counter an bundle everything that belongs there separately from the rest:
const Counter = () => {
const Init = state => ({ ...state, counter: 0 })
const Decr = state => [
{ ...state, counter: state.counter - 1 },
dispatch => dispatch(ScorePoint)
]
const Incr = state => [
{ ...state, counter: state.counter + 1 },
dispatch => dispatch(ScorePoint),
state.counter === 9 && (dispatch => dispatch(EndGame)),
]
return {Init, Incr, Decr}
}
There is also this part from the view:
<div>
<h1>${state.counter}</h1>
<button onclick=${Decr}>-</button>
<button onclick=${Incr}>+</button>
</div>
Let's put it in the component as well.
const Counter = () => {
//...
const view = state => html`
<div>
<h1>${state.counter}</h1>
<button onclick=${Decr}>-</button>
<button onclick=${Incr}>+</button>
</div>`
return {Init, view}
}
Now for the app to use this component, we need to instantiate it :
const counter = Counter()
(Why though? – We'll get to that in a sec)
In the Play
action we replace InitCounter
with counter.Init
, and in the view we replace the counter-html with: ${counter.view(state)}
This way everything related to both behavior and appearance of a counter is defined in one place. As long as we return the same interface ({Init, view}
) we can change whatever we want about the counter without affecting the rest of the app.
However, that same assurance doesn't hold in the other direction! This component is dependent on keeping its state in state.counter
. Also on the EndGame
and ScorePoint
actions being available in the scope.
A Reusable Counter Component
Instead of relying on certain external facts to be true, the necessary information should be provided to the component from whoever consumes.
We will need to be given a get
function that can extract the counter state from the full app state.
We will also need a set
function that can produce a new full app state given the current full state and a new counter state.
Also, we need an onChange
action we can dispatch when the value changes. That way it can be up to the consumer wether to score a point, end the game or do something else entirely.
Adapting the counter component to these changes, it looks like:
const Counter = ({get, set, onChange}) => {
const Init = state => set(state, 0)
const Decr = state => [
set(state, get(state) - 1),
dispatch => dispatch(onChange, get(state) - 1)
]
const Incr = state => [
set(state, get(state) + 1),
dispatch => dispatch(onChange, get(state) + 1)
]
const view = state => html`
<div>
<h1>${get(state}</h1>
<button onclick=${Decr}>-</button>
<button onclick=${Incr}>+</button>
</div>`
return { Init, view }
}
Instantiating the component now looks like:
const counter = Counter({
get: state => state.counter,
set: (state, counter) => ({...state, counter}),
onChange: (state, value) => [
state,
dispatch => dispatch(ScorePoint),
value === 10 && (dispatch => dispatch(EndGame))
]
})
Since everything the counter needs to know about the outside world is provided in this instantiation, it is no longer sensitive to changes outside of it. Moreover, we can easily have multiple counters in the same app, for different purposes without implementing them separately. We just instantiate the counter component multiple times for different states. In other words, this component is reusable!
Composing App Components
I started calling this thing a 'component' because it is composable. Several components like this could be combined together to define our app.
Rather than walk you through how to componentize the other domains, here's the same fun game again – this time with different domains componentized and composed to define the app:
Especially notice how the counter is instantiated as a sub-component of game. Also how the game's two views are passed as arguments to the flow component.
There's nothing remarkable about this structure in particular – it could be done in a myriad of ways. This one just made sense to me.
If something is unclear please drop a question in the comment section!
Final Thoughts
So, am I suggesting you go refactor your entire app now? No, definitely not. I made the game fully componentized just for illustrative purposes. As you can see it can get a bit boilerplaty and besides, it's not always so clear how to draw the line between domains.
So when should you use this approach? The main win is the separation that makes it safe to work on one thing without accidentally breaking something else. So if you have some especially tricky logic that you don't want getting in the way of your other work, you could tuck it away in a component. Another example might be if your app has several different pages with different things going on in each, you could make it easier for a team to work on different pages in parallell without merge-conflicts. Also: reusability is a big win. If you have multiple instances of the same behavior, you want to reuse it one way or another.
If you find it useful, I'd love to hear about it!
Special thanks to @mdkq on the Hyperapp Discord, for reminding me I needed to publish this, and also inspiring me to reconsider some things i had dismissed earlier.
Top comments (1)
Vou precisar, justamente para implementar uma aplicação multi-páginas.