Skip to content

edkelly303/elm-composer

Folders and files

NameName
Last commit message
Last commit date

Latest commit

ย 

History

92 Commits
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 
ย 

Repository files navigation

elm-composer

Compose Elm apps with typed message passing

The problem

Objective โ˜• Flat TEA โ˜• Nested TEA ๐ŸŽผ elm-composer
Component model is independent of app model โŒ โŒ app model contains component model โœ…
Component msg is independent of app msg โŒ โŒ component msg is wrapped in app msg โœ…
Component handles its own initialisation โŒ โŒ app calls component's init โœ…
Component handles its own updates โŒ โŒ app's update calls component's update โœ…
Component handles its own subscriptions โŒ โŒ app's subscriptions calls component's subscriptions โœ…
Component generates its own view โŒ โŒ app's view calls component's view โœ… (optional)
Pure Elm (no ports, no JS, no codegen) โœ… โœ… โœ…
No functions in Msg or Model โœ… โœ… โœ…
Framework agnostic (e.g. works with elm-ui) โœ… โœ… โœ…
Easy types โœ… ๐Ÿค” ๐Ÿ™ˆ
Clear Errors โœ… ๐Ÿค” ๐Ÿคฎ

elm-composer's solution

import Composer.Element as CE
import Browser

main =
  CE.integrate myApp
    |> CE.withSandbox counter
    |> CE.withElement clock
    |> CE.withSimpleComponent stopwatch
    |> CE.withComponent timer (\toApp appModel -> { timerExpired = TimerExpired } )
    |> CE.groupedAs 
      (\counter_ clock_ stopwatch_ timer_ -> 
        { counter = counter_
        , clock = clock_ 
        , stopwatch = stopwatch_
        , timer = timer_
        }
      )
    |> Browser.element

Huh! Ok, talk me through it. What is myApp here?

myApp is our main application, which we will be integrating various components into. It's going to be a Browser.element application, which is why we've imported the Composer.Element module here.

myApp should be a record that contains the same four fields that we would usually pass to Browser.element: init, update, view and subscriptions. But there are a couple of things we'll need to change.

  • First, we need to add two extra arguments to myApp's init, update, view and subscriptions functions. Let's call those arguments components and toSelf. For example:
    - init flags = ...
    + init components toSelf flags = ...
    
    - update msg model = ...
    + update components toSelf msg model = ...
    
    - view model = ...
    + view components toSelf model = ...
    
    - subscriptions model = ...
    + subscriptions components toSelf model = ...
  • Second, if we want myApp to be able to send itself any Msgs, we need to wrap those Msgs in toSelf. For example, in our init and update functions:
    - Task.perform (\now -> TimeUpdated now) Time.now
    + Task.perform (\now -> toSelf (TimeUpdated now)) Time.now
    In our view function:
    - Html.Events.onClick Increment
    + Html.Events.onClick (toSelf Increment)
    And in our subscriptions function:
    - Time.every 1000 (\now -> TimeUpdated now)
    + Time.every 1000 (\now -> toSelf (TimeUpdated now))`

Hmmm, I'll trust you on toSelf... but what's the components argument about?

Patience, friend! First, let's put together the simplest possible example of an app with an integrated component.

Make a file called Main.elm, and add the following code::

-- in Main.elm

module Main exposing (main)

import Html

myApp = 
  { init = 
      \components toSelf flags -> 
        ((), Cmd.none)
  , update = 
      \components toSelf msg model -> 
        ((), Cmd.none)
  , view = 
      \components toSelf model -> 
        Html.div [] 
          [ Html.text "Hello world" ]
  , subscriptions = 
      \components toSelf model -> 
        Sub.none
  }

Yes, that's right, it's an app that does absolutely nothing except display "Hello world" in the browser.

We're going to integrate the Counter example from the Elm Guide into this app. Imagine we've copy-pasted the code from the Elm Guide into a file called Counter.elm, and added a module declaration at the top like this:

-- in Counter.elm

module Counter exposing (init, update, view)

Switch back to our Main.elm file, and add this:

-- in Main.elm

import Counter

counter = 
  { init = Counter.init
  , update = Counter.update
  , view = Counter.view
  }

Now, let's use elm-composer to write our main function:

-- in Main.elm

import Browser
import Composer.Element as CE

main = 
  CE.integrate myApp
  |> CE.withSandbox counter
  |> CE.groupedAs 
    (\counterView -> 
      { counterView = counterView 
      }
    )
  |> Browser.element

We can run this in elm reactor or (even better) elm-watch, and we should see... hmmm... just "Hello world" in our browser. What happened to our counter?

Well, we need to do one more bit of wiring to make this work. Let's revisit myApp's view function:

  -- in Main.elm

  myApp =
    { ...
    , view = 
        \components toSelf model -> 
          Html.div [] 
-           [ Html.text "Hello world" ]
+           [ Html.text "Here's your counter!"
+           , components.counterView
+           ]

Ta-daaa! We should see the Elm Guide's counter in all its glory!

Wait, what the heck actually happened there?

In our main function, we called a function called groupedAs. The groupedAs function takes the output of our counter component's view function (i.e. a value of type Html Counter.Msg), converts its msg type to a type that is compatible with elm-composer, and puts it into a record, under a field called counterView.

At runtime, elm-composer passes this record into myApps init, update, view and subscriptions functions as the first argument, which we've called components.

So, in myApp's view function, if we call components.counterView, we'll display the output of the counter's view function.

Climbing the power curve

Let's recap: we now have a component, counter, which is effectively a Browser.sandbox program, and it's embedded in our main application, myApp.

A sandbox is great if we just want a component that manages a little bit of state and provides a little bit of interactivity via HTML events.

But what if we want to do something a bit more powerful - for example, what if we want a component that can send messages via Cmd from its init and update functions and receive messages via Sub in its subscriptions?

Well, in a very similar analogy to elm-browser, we can create a component that is the equivalent of a Browser.element, using the withElement function.

Let's integrate the Clock example from the Elm Guide into this app. Copy-paste the code from the Elm Guide into a file called Clock.elm, and add a module declaration at the top like this:

-- in Clock.elm

module Clock exposing (init, update, view, subscriptions)

And then add a new component definition in Main.elm:

-- in Main.elm

import Clock

clock = 
  { init = Clock.init
  , update = Clock.update
  , view = Clock.view
  , subscriptions = Clock.subscriptions
  }

Then add it to our main function:

  -- in Main.elm

  main = 
    CE.integrate myApp
    |> CE.withSandbox counter
+   |> CE.withElement clock
    |> CE.groupedAs 
-      (\counterView -> 
+      (\counterView clockView-> 
         { counterView = counterView 
+        , clockView = clockView
         }
       )
    |> Browser.element

And update our view:

  -- in Main.elm
  
  myApp = 
    { ...
    , view = 
        \components toSelf model -> 
          Html.div [] 
            [ Html.text "Here's your counter!"
            , components.counterView
+           , Html.text "And here's your clock!"
+           , components.clockView
            ]

OLD STUFF

view versus interface

Why did we rename the component's view function to interface? I'm so glad you asked!

In the first component we write, we will probably do something like this:

type alias CounterModel =
  { count : Int
  , ... other fields
  }

type CounterMsg
  = Increment
  | ... other variants

counter =
  { init = ...
  , update = ...
  , subscriptions = ...
  , interface =
    \toSelf model ->
      Html.button
        [ Html.Events.onClick (toSelf Increment) ]
        [ Html.text (String.fromInt model.count) ]
  }

Our counter component's interface function simply returns a value of type Html msg.

Look back at the function that we passed to Composer.Element.compose in our main function:

    |> Composer.Element.compose (\counter_ clock_ -> { counter = counter_, clock = clock_ })

This function takes the return value of each component's interface function and inserts it into a record. The counter component's interface is in the counter field of the record, and the clock component's interface is in the clock field.

This record is then passed into our main app's init, update, view, and subscriptions functions as their first argument. Let's call that argument "components".

Now, if we want to render the HTML returned from our counter component's interface, all we need to do is add components.counter somewhere in our main app's view function, like so:

myApp =
  { init = ...
  , update = ...
  , subscriptions = ...
  , view =
    \components toSelf model ->
      Html.div []
        [ Html.text "Behold my wondrous counter component!"
        , components.counter
        ] 
  }

But! Unlike a normal Elm app's view function, there is no need for our interface function to return Html msg. It can return any type we like.

So, maybe instead of having the component render itself and forcing our main app to live with those rendering decisions, we could instead provide the ingredients the main app will need to render the component properly:

counter =
  { init = ...
  , update = ...
  , subscriptions = ...
  , interface =
    \toSelf model ->
      { count = model.count
      , increment = toSelf Increment
      }
  }

This isn't simply a view, it's an interface - a way to control how the main app is allowed to interact with our component. And we use it thus:

myApp =
  { init = ...
  , update = ...
  , subscriptions = ...
  , view =
    \components toSelf model ->
      Html.div []
        [ Html.text "Behold my wondrous counter component which I have rendered myself!"
        , Html.button
          [ Html.Events.onClick components.counter.increment ]
          [ Html.text (String.fromInt components.counter.count) ]
        ] 
  }

The neat thing about this is that it lets us enforce as much or as little encapsulation of our components as we please.

  • The main app cannot directly see what is in the component's model, unless our interface function returns that model.
  • The main app cannot send messages to the component, unless our interface function returns the component's toSelf message constructor, or provides specific message variants for the main app to send.

Couldn't we make it a bit more complicated?

My friend, we are programmers - we can always make things more complicated.

So far, we've got a component that can provide an interface that controls how our main app can interact with it.

But what if we also want the converse: an interface that controls how our component can interact with our main app's model, and specifies what messages it can send to the main app?

This is where Composer.component comes in.

Under the covers, when we call Composer.componentSimple, what's really happening is this:

import Composer.Element as Composer
import Browser

main =
  Composer.app app
-    |> Composer.componentSimple counter
+    |> Composer.component
+        counter
+        (\toApp appModel -> ())
    |> Composer.Element.componentSimple clock
    |> Composer.Element.compose (\counter_ clock_ -> { counter = counter_, clock = clock_ })
    |> Browser.element

As you can see, there's now an extra function passed to each component that specifies the interface that the main app provides to the component. In this case, we simply return the unit type (), and that () gets passed to our component's update, view and subscriptions functions as the app argument.

Now, just as with the interface function we defined earlier in our component, this new app interface function can return any type we like. Instead of returning (), we might decide expose a limited subset of the variants of the main app's msg type, or a subset of fields from the main app's model.

To see how this might work in practice, check out the DnD example in the examples folder.

About

Compose Elm apps with typed message passing

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages