Define an app in a simple, safe and declarative way using state-interface

You can now define an elm app like this:

{ initialState : YourState
, interface : YourState -> Interface YourState
}

State is equivalent to “model” and Interface is all incoming and outgoing effects,
with the ability to come back with a new state.

Here’s a simple app that shows a number which gets incremented every second

{ initialState = 0
, interface =
    \state ->
        [ state |> String.fromInt |> Dom.text |> Dom.render
        , Time.periodicallyListen Duration.second
            |> interfaceFutureMap (\_ -> state + 1)
        ]
            |> interfaceBatch
}

A lot works the same as in TEA. A few changes make it declarative, safe and simple:

simple and beginner friendly

  • one Interface instead of Sub, Cmd, Task, init command and view
  • no sandbox vs element vs document vs application. Any app is { initialState, interface } and complexity like an event type, title and url handling can be introduced gradually

safe

Once an event (“msg”) comes to your update in TEA, you don’t have the latest state of the same kind it was fired in.
This can get quite boilerplate-y if you have lots of branches (like in a story game) or add shared events and all that.

in TEA

type Page
    = HomePage HomePage
    | SettingsPage SettingsPage

type Msg
    = HomePageMsg HomePageMsg
    | SettingsPageMsg SettingsPageMsg

update msg page =
    case page of
        HomePage homePage ->
            case msg of
                HomePageMsg homePageMsg ->
                    ...
                
                -- this should be impossible
                _ ->
                    page

        SettingsPage settingsPage ->
            case msg of
                SettingsPageMsg settingsPageMsg ->
                    ...
                
                -- this should be impossible
                _ ->
                    page

in state-interface

type Page
    = HomePage HomePage
    | SettingsPage SettingsPage

interface page =
    case page of
        HomePage homePage ->
            ..interfaces..
                |> interfaceFutureMap
                    (\homePageEvent ->
                        ..you can use homePage here..
                    )

        SettingsPage settingsPage ->
            ..interfaces..
                |> interfaceFutureMap
                    (\settingPageEvent ->
                        ..you can use settingsPage here..
                    )

or alternatively

type Page
    = HomePage HomePage
    | SettingsPage SettingsPage

type Event
    = HomePageEvent { state : HomePage, event : HomePageEvent }
    | SettingsPageEvent { state : SettingsPage, event : SettingsPageEvent }

interface page =
    (case page of
        HomePage homePage ->
            ..interfaces..
                |> interfaceFutureMap
                    (\homePageEvent ->
                        HomePageEvent { state = homePage, event = homePageEvent }
                    )

        SettingsPage settingsPage ->
            ..interfaces..
                |> interfaceFutureMap
                    (\settingsPageEvent ->
                        SettingsPageEvent { state = settingsPage, event = settingsPageEvent }
                    )
    )
        |> interfaceFutureMap
            (\event ->
                case event of
                    HomePageEvent homePage ->
                        ..homePage.state will be up to date..

                    SettingsPageEvent settingsPage ->
                        ..settingsPage.state will be up to date..
            )

It can seem odd that the state in the event that arrives contains the latest info, even though we seem to send an event with a state that will be outdated in the near future.
Interfaces that are fired in the past don’t actually save their event handling.
Only the latest interface of the same kind gets that privilege!

declarative

Often, there isn’t a single event after which to initiate actions, requests, etc. on the outside. It’s more a matter of what we already know

in TEA

update msg state =
    case msg of
        MenuGamepadStartClicked ->
            ( { state | mode = initialGameState Gamepad }
            , case state.audio of
                Nothing ->
                    loadAudioCmd ... |> Cmd.map AudioLoaded
                
                Just _ ->
                    Cmd.none
            )
        
        MenuStartButtonClicked ->
            ( { state | mode = initialGameState Mouse }
            , case state.audio of
                Nothing ->
                    loadAudioCmd ... |> Cmd.map AudioLoaded
                
                Just _ ->
                    Cmd.none
            )
        
        AudioLoaded loaded ->
            ( { state | audio = loaded |> Just }, Cmd.none )

same with effects like starting and stopping audio etc.

in state-interface, we can list those effects very similar to subscriptions or view

menuInterface state =
    [ case state.mode of
        MenuState ->
            interfaceNone

        GameState _ ->
            case state.audio of
                Nothing ->
                    Audio.sourceLoad ...
                        |> interfaceFutureMap AudioLoaded
                        
                Just _ ->
                    interfaceNone
    , ..user interface..
    ]
        |> interfaceBatch
        |> interfaceFutureMap
            (\menuEvent ->
                case menuEvent of
                    GamepadStartClicked ->
                        { state | mode = initialGameState Gamepad }
                    
                    StartButtonClicked ->
                        { state | mode = initialGameState Mouse }
                    
                    AudioLoaded loaded ->
                        { state | audio = loaded |> Just }
            )

The state-interface documentation has examples and explanations so you can develop an intuition.

web APIs

Basically all elm/ browser effects have equivalents in the state-interface package.
And audio, localstorage, gamepads, websockets, geo location, notification and more are also included.

16 Likes

I can’t wait to kick the tires on this when back at my computer. Am I correct that this allows us to couple state management and views close to each other, sort of like how react and hooks are?

1 Like

Nice! It feels “natural” and logical for lack of a better word.

The argument for not having to deal with stale messages motivated me to take a look as that’s one of the aspects of TEA I find quite unpleasant actually.

Your approach reminded me of the Purescript Halogen library (which I’ve only toyed with). Is this where your inspiration came from?

Here’s my take at the obligatory simple counter example. I kept some classical Elm terms and idioms for familiarity. Indeed we’re not too far off what we already know.

port module Main exposing (main)

import Json.Encode as E
import Web
import Web.Dom


port toJs : E.Value -> Cmd event


port fromJs : (E.Value -> event) -> Sub event


type alias Model =
    Int


type Msg
    = Inc
    | Dec


update : Msg -> Model -> Model
update msg model =
    case msg of
        Inc ->
            model + 1

        Dec ->
            max 0 (model - 1)


view : Model -> Web.Dom.Node Msg
view model =
    let
        div =
            Web.Dom.element "div"

        span =
            Web.Dom.element "span"

        h1 =
            Web.Dom.element "h1"

        text =
            Web.Dom.text

        btn : future -> String -> Web.Dom.Node future
        btn msg text_ =
            Web.Dom.element "button"
                [ Web.Dom.listenTo "click" |> Web.Dom.modifierFutureMap (\_ -> msg) ]
                [ text text_ ]

        padding =
            Web.Dom.style "padding" "0px 10px"
    in
    div []
        [ h1 []
            [ Web.Dom.text "Counter example"
            ]
        , div []
            [ span []
                [ btn Dec "DEC"
                , span [ padding ] [ text <| String.fromInt model ]
                , btn Inc "INC"
                ]
            ]
        ]


flip : (a -> b -> c) -> b -> a -> c
flip f a b =
    f b a


interface : Model -> Web.Interface Model
interface model =
    [ Web.Dom.render (view model) ]
        |> Web.interfaceBatch
        |> Web.interfaceFutureMap
            (flip update model)


main : Web.Program Model
main =
    Web.program
        { initialState = 0
        , interface = interface
        , ports = { fromJs = fromJs, toJs = toJs }
        }

Playing with this a bit as I work on a game and hit a little bit of a surprise, I’m allowed to add multiple top level Web.Dom.Node but only the last one renders. Wasn’t hard to figure out but it did catch me off guard as there’s nothing in the API that indicates that only the last will render. It has me wondering what else will hit the same issue.

1 Like

Every interface has an Id assigned internally that’s used to quickly diff interfaces, wire js events back in correctly and remove/abort started js interface implementations.
For Web.Dom.render, the Id is basically just "DomNodeRender" (what exactly you draw is not part of the Id). Here’s the interface to id function: elm-state-interface/src/Web.elm at main · lue-bird/elm-state-interface · GitHub.

For sure this is something to make users aware of, suggestions warmly welcomed!

2 Likes

Definitely going to have to wrap my head around this. Very exciting to see this type of project/approach.

1 Like

This topic was automatically closed 10 days after the last reply. New replies are no longer allowed.