DEV Community

loading...
Cover image for Modular Hyperapp - Part 7

Modular Hyperapp - Part 7

zaceno profile image Zacharias Enochsson ・5 min read

Welcome to this last part of my series on Modular Hyperapp. I'll summarize what we've gone over so far, then complete the picture before signing off with some final reflections.

Recap

Views

Reusable parts of the view can be broken out as functions called views or view-components. Views are provided the values and actions they need through an object I call the model for the view.

Primitive Transforms

In the same vein, reusable bits of logic can be broken out from actions in the form of primitive transforms. They take a value, and return a new value – not the full state.

Domains

We brought up the concept of "domains" - an area/feature/aspect of your app that makes sense to think about in isolation from the rest. All logic pertaining to a certain domain makes sense to gather in a module.

Models

A module could have several views and subscription-components. Since they belong to the same domain, they need roughly the same models. It makes sense to have one common model format for all view- and subscription-components of a module.

Wired Actions / Models

In order to move action- and model-definitions to the modules for their respective domains, they need to be defined dynamically in a function I've been calling wire.

Actions in a wire function know how to operate on a particular value through the getter and a setter, given as arguments. wire return a function which, given the current state, returns the model for the module's views.

App Modules

The value that a module's actions operate on is often complex, Therefore modules should also export a value-initializer I've called init.

Modules containing an init plus all the actions and views (and subscriptions) a domain needs, I call "app modules" since they can be run as standalone apps.

Wiring App Modules to Others

A wire may also take mapped transforms as arguments – functions that tell it what to do when "something happens". wire can also return mapped transform besides the model-function for passing as arguments to other wires. In this way, modules can be wired together to form a more complicated app.

Make Modules from Modules

But not just apps! modules could be wired together to form other modules too:

import * as foo from './foo.js'
import * as bar from './bar.js'

export const init = () => ({
    myfoo: foo.init()
    mybar: bar.init()     
}

export const wire = ({getter, setter}) => {

    const myfoo = foo.wire({
        getter: state => getter(state).myfoo,
        setter: (state, myfoo) => setter(state, {
            ...getter(state),
            myfoo,
        }),
        onSnap: bar.crackle,
    })

    const mybar = bar.wire({
        getter: state => getter(state).mybar,
        setter: (state, mybar) => setter(state. {
            ...getter(state),
            mybar,
        }),
    })

    return {
        pop: foo.pop,
        model:  state => ({
            myfoo: myfoo.model(state),
            mybar: mybar.model(state),
        })
    }
}

export const view = (model) => h('div', {}, [
    h('p', {}, [ text('Foo:'), foo.view(model.myfoo) ]),
    h('p', {}, [ text('Bar:'), bar.view(model.mybar) ]),
])

In this way, an app can be structured as a tree app-modules. Even tiny things that repeat a lot, like a button with some recurring behavior, could be defined once and reused in many places.

Dynamic Instances

There is just one more thing we need to add to complete the picture: What if there can be multiple instances of some module's values in the state, and we don't know them from the start?

For instance: task-items in a to-do list. – How could we define getters and setters for a task we don't yet know will exist? We could parameterize the getters and setters, like this:

// this is task-list.js

import * as task from './task.js'

const tasks = task.wire({
    getter: (state, id) => state.tasks[id],
    setter: (state, todo, id) => ({
        ...state,
        tasks: {...state.tasks, [id]: todo}
    }),
})

The id parameter refers to a specific instance of a task.
Actions in task.js will need to get id in the payload, in order to pass it on to getter and setter.

//this is task.js

export const wire = ({getter, setter}) => {

    const SetText = (state, {id, text}) => setter(
        state,
        {...getter(state, id), text},
        id
    )
    //...

The only way actions for actions to get the id as a payload, is through the model function:

//this is task.js

export const wire = ({getter, setter}) => {

    return {
        model: (state, id) => ({
            ...getter(state, id),
            SetText: (_, event) =>
                [SetText, {id, text: event.target.value}],
            //...
        })
        //...
    }
}
//...        

The task-list.js model can now create a sub-model for each task that happens to exist, each time the state updates:

//this is task-list.js

//...

const model = (state) => ({
    //...
    tasks: Object.keys(state.tasks).map(id =>    
        tasks.model(state, id)
    )
})

//...

id doesn't have to be a number or a string. It could be a complex object representing a path through a whole tree of dynamic instances. That way you could even have dynamic lists in dynamic lists!

Finally!

And here we finally are, at the end of the series! Congratulations and well done for sticking it out all the way!

It's been a long and abstract journey to this point, where we finally see that any app – no matter how large or complex – can be made up of self-contained, manageable modules, developed individually and later combined.

Closing Thoughts

Such structure is similar to what you'd have with React or Vue – which begs the question: Why not just use React or Vue? After all, this app-module pattern I've presented is rather verbose with all its getters, setters, states and ids.

I wouldn't presume to tell you the right framework to use in your particular situation, but allow me to make a case for Hyperapp:

First, I wrote this series to show how far you could take modularization if you need to. Hyperapp leaves it up to you to use just the techniques and patterns that help you.

If you want to dig in to a really over-modular example, I threw everything I had at this multi-todo-list app

Contrast that with more rigid frameworks where everything needs to be a component. You need to decide what each component should do before making it. It seems easy at first, but as you add more components, sharing state between them gets more convoluted – an issue which has led to the development of central-state-stores like Redux and Vuex. All the mechanisms for coordinating components and state come with their own APIs to learn. How much time have you spent pouring over docs and tutorials to figure out React-hooks, redux-saga, et.c? – And making the pieces fit together?

Hyperapp starts from the other end: state is global and shared by default. Nothing is encapsulated until you want to make it that way. The API is minimal and dead simple – it doesn't do everything you'd want, but it also doesn't get in the way. You have the full power of javascript at your disposal to structure your app however you please. If you have a bug, odds are it's because you used javascript wrong, not Hyperapp.

The examples here could be made much more concise and readable with some library code. But then I'd be explaining how to use my library code, and not conveying the ideas behind it.

I expect you will figure out patterns and helpers that suit your style. They will probably look different from mine, and that's fine! Regardless of what you get up to, I hope and believe that the ideas I've presented here will help!

Discussion (0)

pic
Editor guide