Composants avec état dans ELM

Il est souvent affirmé que les développeurs Elm devraient éviter de considérer leurs vues comme des composants étatiques. Bien que cela soit effectivement une pratique de conception générale recommandée, parfois vous pouvez vouloir rendre vos vues réutilisables (par exemple, sur différentes pages ou projets), et si elles sont fournies avec un état… vous finissez par copier-coller beaucoup de choses.

Nous avons récemment publié elm-daterange-picker, un sélecteur de plage de dates écrit en Elm. C’était l’occasion parfaite d’investiguer à quoi ressemblerait une API raisonnable pour un composant de vue étatique réutilisable.

L’attribut alt de cette image est vide, son nom de fichier est image.png.

De nombreux packages Elm orientés composants/widgets présentent une API Architecture Elm (TEA) plutôt brute, exposant directement Model, Msg(..), init, update et view, vous permettant essentiellement d’importer ce qui définit une application réelle et de l’intégrer dans votre propre application.

Avec ceux-ci, vous finissez généralement par écrire des choses comme ceci :

Elm
import Counter

type alias Model =
    { counter : Counter.Model
    , value : Maybe Int
    }


type Msg
    = CounterMsg Counter.Msg


init : () -> ( Model, Cmd Msg )
init _ =
    ( { counter = Counter.init, value = Nothing }
    , Cmd.none
    )


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        CounterMsg counterMsg ->
            let
                ( newCounterModel, newCounterCommands ) =
                    Counter.update counterMsg
            in
            ( { model
                | counter = newCounterModel
                , value = case counterMsg of
                    Counter.Apply value ->
                        Just value

                    _ ->
                        Nothing
              }
            , newCommands |> Cmd.map CounterMsg
            )
view : Model -> Html Msg
view model =
    div []
        [ Counter.view model.counter
            |> Html.map CounterMsg
        , text (String.fromInt model.value)
        ]

Cela fonctionne certainement, mais soyons francs pendant une minute et admettons que c’est super verbeux et pas très convivial pour les développeurs :

  • Vous devez utiliser Cmd.map et Html.map ici et là
  • Vous devez faire correspondre des Msg de Counter pour intercepter tout événement qui vous intéresse…
  • … ce qui signifie que Counter expose tous les Msgs, qui sont des détails d’implémentation sur lesquels vous comptez maintenant.

Il y a une autre façon, qu’Evan a expliquée dans son package elm-sortable-table désormais obsolète. Parmi les nombreuses bonnes idées qu’il a eues, une idée m’a semblé brillamment simple mais efficace pour simplifier la conception d’API de tels composants de vue étatique :

  • Les mises à jour d’état peuvent être gérées directement depuis les gestionnaires d’événements !

Imaginons un simple compteur ; et si, en cliquant sur le bouton d’incrément, au lieu d’appeler onClick avec un message Increment quelconque, nous appellerions un message fourni par l’utilisateur avec le nouvel état du compteur mis à jour en conséquence ?

Elm
-- Counter.elm
view : (Int -> msg) -> Int -> Html msg
view toMsg counter =
    button [ onClick (toMsg (counter + 1)) ]
        [ text "increment" ]

Ou si vous voulez utiliser un type opaque, ce qui est une excellente idée pour maintenir la plus petite surface d’API possible :

Elm
-- Counter.elm
type State
    = State Int

view : (State -> msg) -> State -> Html msg
view toMsg (State value) =
    button [ onClick (toMsg (State (value + 1))) ]
        [ text "increment" ]

Notez que comme nous traitons un état de compteur, nous ne nous sommes pas embêtés à avoir autre chose qu’un simple Int pour le représenter. Mais vous pourriez bien sûr avoir un enregistrement ou tout ce que vous voulez.

Gérer la mise à jour de l’état interne pourrait être simplement de créer des Msg internes et non exposés et des fonctions update :

Elm
-- Counter.elm
type State
    = State Int

type Msg
    = Dec
    | Inc

update : Msg -> Int -> Int
update msg value =
    case msg of
        Dec ->
            value - 1

        Inc ->
            value + 1

view : (State -> msg) -> State -> Html msg
view toMsg (State value) =
    div []
        [ button [ onClick (toMsg (State (update Dec

 value))) ]
            [ text "-" ]
        , div [] [ text (String.fromInt value) ]
        , button [ onClick (toMsg (State (update Inc value))) ]
            [ text "+" ]
        ]

Ou, si vous préférez travailler avec un type opaque :

Elm
-- Counter.elm
type State
    = State Int

type Msg
    = Dec
    | Inc

update : Msg -> State -> State
update msg (State value) =
    case msg of
        Dec ->
            State (value - 1)

        Inc ->
            State (value + 1)

view : (State -> msg) -> State -> Html msg
view toMsg (State value) =
    div []
        [ button [ onClick (toMsg (update Dec (State value))) ]
            [ text "-" ]
        , div [] [ text (String.fromInt value) ]
        , button [ onClick (toMsg (update Inc (State value))) ]
            [ text "+" ]
        ]

Et maintenant, pour l’intégrer, c’est facile :

Elm
-- Main.elm
import Counter exposing (State, Msg(..), view)

type alias Model =
    { counter : State
    , value : Maybe Int
    }

type Msg
    = CounterMsg Counter.Msg

init : () -> ( Model, Cmd Msg )
init _ =
    ( { counter = State 0, value = Nothing }
    , Cmd.none
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        CounterMsg counterMsg ->
            let
                ( newCounterState, _ ) =
                    Counter.update counterMsg model.counter
            in
            ( { model | counter = newCounterState }
            , Cmd.none
            )

view : Model -> Html Msg
view model =
    div []
        [ Counter.view CounterMsg model.counter
        ]

C’est ça ! Toutes les responsabilités de gestion des mises à jour de l’état interne sont dans le composant lui-même. Et pour utiliser un événement interne spécifique, vous pouvez simplement intercepter les messages appropriés dans votre update de modèle principal.

Cette approche a le mérite d’être simple, intuitive et réduit considérablement la surface de l’API exposée aux utilisateurs du composant.

La seule critique que je peux penser est que vous vous retrouverez à devoir manipuler l’état interne directement, ce qui peut sembler contre-intuitif pour certains, mais honnêtement, je pense que c’est une très petite concession à faire pour la simplicité et la facilité d’utilisation que cela apporte.

Partagez c'est sympa !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *