DEV Community 👩‍💻👨‍💻

Cover image for A Fresh Take on Modular Hyperapp
Zacharias Enochsson
Zacharias Enochsson

Posted on • Updated on

A Fresh Take on Modular Hyperapp

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
      };
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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 })
Enter fullscreen mode Exit fullscreen mode

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)
]
Enter fullscreen mode Exit fullscreen mode

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.

single circuit board

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.

split up circuit board

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)),
]
Enter fullscreen mode Exit fullscreen mode

Of course we need to implement the EndGame action, for affecting the mode state – another domain:

const EndGame = state => ({ ...state, mode: 'finish' })
Enter fullscreen mode Exit fullscreen mode

The Play action:

const Play = state => ({
  mode: "play",
  counter: 0,
  score: 0
})
Enter fullscreen mode Exit fullscreen mode

... 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)
  }
]
Enter fullscreen mode Exit fullscreen mode

And now those actions need to be defined as well.

const InitScore = state => ({...state, score: 0})
const InitCounter = state => ({...state, counter: 0})
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

There is also this part from the view:

<div>
  <h1>${state.counter}</h1>
  <button onclick=${Decr}>-</button>
  <button onclick=${Incr}>+</button>
</div>
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

Now for the app to use this component, we need to instantiate it :

const counter = Counter()
Enter fullscreen mode Exit fullscreen mode

(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 }
}
Enter fullscreen mode Exit fullscreen mode

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))
  ]
})    
Enter fullscreen mode Exit fullscreen mode

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 (0)

Let's Get Hacking

Join the DEV x Linode Hackathon 2022 and use your ingenuity and creativity to build using Linode.

Join the Hackathon <-