New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feedback needed: Fractal `update` functions thanks to lenses #973

Closed
atomrc opened this Issue Aug 19, 2018 · 4 comments

Comments

Projects
None yet
3 participants
@atomrc

atomrc commented Aug 19, 2018

Hi guys,

Quick disclaimer

I am very new to Elm and while reading An introduction to Elm something bugged me. The fact that only views are shown as composable (cf. https://guide.elm-lang.org/reuse).

I tried to find an elegant solution to let sub components/modules/functional slices of the app/... update their own model without having to know about the top level app model.

This is a very humble proposal and it's probably missing a lot of cases (for example Command are not handled in my current proposal). I am looking for feedback on this idea. There might be a way that I missed to do that already, so I am up for any feedback :)

It's highly inspired of the Cycle.js way to do that (with cycle-onionify and lenses https://github.com/staltz/cycle-onionify).

In the rest of the issue I'll use the term component to talk about a functional slice of an app (something that knows how to render, that possesses the business logic to update its own state and that returns some effects (the former is not part of this proposal)).

My problem: Components cannot define their own update function

So far, what I see is that there can only be a single signature for any update function of the app update : AppMessage -> AppModel -> AppModel.
Whether you are building an update function for the root component or for a nested leaf component, the function will always take a full AppModel.
The problem is that leaf component then need to know about the structure of AppModel and cannot be reused in another context (only their view can be reused).

Concrete example: the checkbox example

Let's work on a concrete example to illustrate my proposal. The checkbox example is a good one I think http://elm-lang.org/examples/checkboxes

The model looks like this

type alias Model =
  { notifications : Bool
  , autoplay : Bool
  , location : Bool
  }

The views are factorized with the checkbox function

The update method is defined by the parent that handles all the messages from the checkbox views.

Lenses to the rescue

The idea is to be able to select a specific slice of the main model and let the component work on that slice independently. This way the component can work without knowing anything about the app structure.

In order to achieve that, I came up with an isolate function that will feed the component the right slice of the model and when an update occurs, update the parent model with the updated model of the component.

type alias Getter parentModel childModel =
    parentModel -> childModel

type alias Setter parentModel childModel =
    parentModel -> childModel -> parentModel

type alias Update message model =
    message -> model -> model

isolate : Getter parentModel childModel -> Setter parentModel childModel -> Update message childModel -> Update message parentModel
isolate getter setter update message model =
    setter model (update message (getter model))

Final result

Here is the current update function of the given example

update : Msg -> Model -> Model
update msg model =
  case msg of
    ToggleNotifications ->
      { model | notifications = not model.notifications }

    ToggleAutoplay ->
      { model | autoplay = not model.autoplay }

    ToggleLocation ->
      { model | location = not model.location }

Here is the isolated version

-- checkbox.elm
checkboxUpdate: Msg -> Bool -> Bool
checkboxUpdate msg model = not model

-- main.elm
notificationUpdate = isolate (model -> model.notification) (model -> notification -> {model | notification = notification}) checkbodUpdate (\_ -> model -> model)
-- same for autoplay and location

update: Msg -> Model -> Model
update msg model = notificationUpdate msg >> autoplayUpdate msg >> locationUpdate msg

This enable easy reuse of components at different level of the application without having to write a reducer every single time.

Limitations

This proposal is not yet a viable option for, as far as I know, two reasons:

  • Commands are not handles (I might have a idea for that)
  • messages are not isolated, meaning that all the checkbox will react to a click on a single one of them (I have no idea yet how to fix that).
  • ... probably some other limitations that I cannot see due to my limited knowledge of Elm.

What I am looking for?

Pretty much anything from "this is a super bad idea because of this, this and that" or "this is not the elm philosophy at all" to "Good idea, I might have some idea to fix the limitations" or "Well, is already possible with this solution".

Working on this was super interesting anyway and I really had a great time working on this, so even if this is a bad idea, my time was not lost at all ;)

BTW: really ❤️ the language!!

@cedricss

This comment has been minimized.

Show comment
Hide comment
@cedricss

cedricss Aug 19, 2018

Thanks for posting this!

A first quick reply to your initial problem. A "nested component" can have its own isolated update function. Consider this change on your example:

type alias Model =
    { notifications : Notifications.Model
    , autoplay : Autoplay.Model
    , location : Location.Model
    }

type Msg
    = NotificationsMsg Notifications.Msg
    | AutoplayMsg Autoplay.Msg
    | LocationMsg Location.Msg

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NotificationsMsg notificationsMsg ->
            let
                ( notificationsModel, notificationsCmd ) =
                    Notifications.update notificationsMsg model.notifications
            in
                ( { model | notifications = notificationsModel }
                , Cmd.Map NotificationsMsg notificationsCmd
                )
        ...

As you can see, the update function in the Notifications module is isolated and has its own Msg -> Model -> ( Model, Cmd Msg ) signature. Also, this module handles itself its messages (Notifications.Msg) and only them. The Main module does not need to know the internals of Notifications, and vice versa. Same principles can be applied for view, init and subscriptions. What do you think?

cedricss commented Aug 19, 2018

Thanks for posting this!

A first quick reply to your initial problem. A "nested component" can have its own isolated update function. Consider this change on your example:

type alias Model =
    { notifications : Notifications.Model
    , autoplay : Autoplay.Model
    , location : Location.Model
    }

type Msg
    = NotificationsMsg Notifications.Msg
    | AutoplayMsg Autoplay.Msg
    | LocationMsg Location.Msg

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NotificationsMsg notificationsMsg ->
            let
                ( notificationsModel, notificationsCmd ) =
                    Notifications.update notificationsMsg model.notifications
            in
                ( { model | notifications = notificationsModel }
                , Cmd.Map NotificationsMsg notificationsCmd
                )
        ...

As you can see, the update function in the Notifications module is isolated and has its own Msg -> Model -> ( Model, Cmd Msg ) signature. Also, this module handles itself its messages (Notifications.Msg) and only them. The Main module does not need to know the internals of Notifications, and vice versa. Same principles can be applied for view, init and subscriptions. What do you think?

@avh4

This comment has been minimized.

Show comment
Hide comment
@avh4

avh4 Aug 19, 2018

Member

Hello, this type of discussion is suited for https://discourse.elm-lang.org (whereas github issues are used by Elm only for specific bugs that will be fixed) -- If you want to discuss further, please move the discussion there, thanks!

Member

avh4 commented Aug 19, 2018

Hello, this type of discussion is suited for https://discourse.elm-lang.org (whereas github issues are used by Elm only for specific bugs that will be fixed) -- If you want to discuss further, please move the discussion there, thanks!

@atomrc

This comment has been minimized.

Show comment
Hide comment
@atomrc

atomrc Aug 19, 2018

Oh sure @avh4 !! I didn't realize there was a discourse for that. My apologies.
I created the topic there (pending at the moment) and will close this Issue now.
Sorry for the noise

atomrc commented Aug 19, 2018

Oh sure @avh4 !! I didn't realize there was a discourse for that. My apologies.
I created the topic there (pending at the moment) and will close this Issue now.
Sorry for the noise

@atomrc atomrc closed this Aug 19, 2018

@atomrc

This comment has been minimized.

Show comment
Hide comment
@atomrc

atomrc Aug 19, 2018

@cedricss just replying here quickly as for the moment the thread is not yet active on discourse.

Thanks for this reply, it is interesting indeed. Quick question though, I am not sure I was clear about the fact that notifications location and autoplay are not 3 different components but 3 "instances" of the same component.

You piece of code would look like this, if I am not mistaking

type alias Model =
    { notifications : Checkbox.Model
    , autoplay : Checkbox.Model
    , location : Checkbox.Model
    }

type Msg
    = NotificationsMsg Checkbox.Msg
    | AutoplayMsg Checkbox.Msg
    | LocationMsg Checkbox.Msg

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NotificationsMsg notificationsMsg ->
            let
                ( notificationsModel, notificationsCmd ) =
                    Checkbox.update notificationsMsg model.notifications
            in
                ( { model | notifications = notificationsModel }
                , Cmd.Map NotificationsMsg notificationsCmd
                )
        ...

It would probably work, I'll need to try this out :)

I believe there is something I am missing though, how do you map checkbox messages to the Msg type? This is not something the parent has control over, Am I wrong?

atomrc commented Aug 19, 2018

@cedricss just replying here quickly as for the moment the thread is not yet active on discourse.

Thanks for this reply, it is interesting indeed. Quick question though, I am not sure I was clear about the fact that notifications location and autoplay are not 3 different components but 3 "instances" of the same component.

You piece of code would look like this, if I am not mistaking

type alias Model =
    { notifications : Checkbox.Model
    , autoplay : Checkbox.Model
    , location : Checkbox.Model
    }

type Msg
    = NotificationsMsg Checkbox.Msg
    | AutoplayMsg Checkbox.Msg
    | LocationMsg Checkbox.Msg

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        NotificationsMsg notificationsMsg ->
            let
                ( notificationsModel, notificationsCmd ) =
                    Checkbox.update notificationsMsg model.notifications
            in
                ( { model | notifications = notificationsModel }
                , Cmd.Map NotificationsMsg notificationsCmd
                )
        ...

It would probably work, I'll need to try this out :)

I believe there is something I am missing though, how do you map checkbox messages to the Msg type? This is not something the parent has control over, Am I wrong?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment