Elm input states

· Allanderek's blog

#elm #programming

A small design decision has come up whilst developing with Elm on both the front-end and the back-end. The issue concerns an input that has some UI state, that doesn't need to be transfered from front-end to the back-end (or vice versa). So I'm going first explain an example of an input that might have some associated UI state that you need to keep track of. Then I'm going to explain how you might represent this has part of a larger entity that needs to be encoded into JSON, and decoded from JSON to send to and from the front-end and back-end.

# Integer inputs

In Elm if you have some input that you want to store as an integer, you have a small problem, because the user might make some intermediate stage that isn't a number, in particular the empty string. So typically what you do is store both the raw input string, and the parsed integer, as a Maybe Int since of course the input might not parse.

1type alias IntInput =
2    { input : String
3    , value : Maybe Int
4    }

You do the obvious thing upon new input:

1updateIntInput : String -> IntInput
2updateIntInput input =
3    { input = input
4    , value = String.toInt input
5    }

# An integer input container

Now suppose you have some data structure that you wish to pass back and forth between the front-end and back-end.

1type alias Driver =
2    { name : String
3    , number : Int
4    }

You can easily define an encoder and decoder:

 1encodeDriver : Driver -> Encode.Value
 2encodeDriver driver =
 3    [ ( "name": driver.name |> Encode.string )
 4    , ( "number": driver.number |> Encode.int )
 5    ]
 6        |> Encode.object 
 7
 8driverDecoder : Decoder Driver
 9driverDecoder =
10    Decode.succeed Driver
11        |> Decode.andMap (Decode.field "name" Decode.string)
12        |> Decode.andMap (Decode.field "number" Decode.int)
13

Now the problem is. Suppose your app allows you to create drivers and send them to the back-end. The existing drivers are also sent from the back-end to the front-end. The problem is, how do you represent the list of drivers in your front-end, bearing in mind that you also have to represent their associated IntInputs.

# Storing the UI state separately

You can store the int inputs, separately to the drivers.

1type alias Model =
2    { ...
3    , drivers : List Driver
4    , driverNumberInputs : List IntInput
5    ...
6    }

In this case whenever you update the number in a driver edit form, you have to update the input, which might also cause you to update the actual driver:

 1update message model =
 2    case message of
 3        ....
 4        DriverNumberInput index string ->
 5            let
 6                newDriverInput =
 7                    updateIntInput input
 8                newDriverInputs =
 9                    List.setAt index newDriverInput model.driverNumberInputs
10                newDrivers =
11                    case newDriverInput.number of
12                        Nothing ->
13                            model.drivers
14                        Just number ->
15                            List.updateAt index (\d -> { d | number = number } ) model.drivers
16            in
17            ( { model 
18                | drivers = newDrivers
19                , driverNumberInputs = newDriverInputs
20                }
21            , Cmd.none
22            )
23        ... 

In a real application, I would probably represent the driver inputs using a dictionary, since all the drivers perhaps have an id field. Maybe you could just use the name for that (what happens if two drivers really have the same name?). Anyway, you can then draw the edit field using the driver input, if no driver input is in the dictionary for that driver you can take that to mean that it has not yet been edited.

 1    let
 2        driver =
 3            ...
 4        driverNumberInput =
 5            Dict.get driver.name model.driverNumberInputs
 6                |> Maybe.withDefault 
 7                    { input = String.fromInt driver.number
 8                    , number = Just driver.number
 9                    }
10    in
11    Html.input
12        [ Attributes.value = driverNumberInput.input
13        ...
14        ]
15        []

# Using a type-argument

The risk with the above solution is that the driver representation, without due care, could become out-of-sync with the driver number inputs. Another possibility is to generalise the representation of a driver:

 1type alias Driver a =
 2    { name : String
 3    , number : a
 4    }
 5
 6encodeDriver : (a -> Int) -> Driver a -> Encode.Value
 7encodeDriver getNumber driver =
 8    [ ( "name": driver.name |> Encode.string )
 9    , ( "number": driver.number |> getNumber |> Encode.int )
10    ]
11        |> Encode.object 
12
13driverDecoder : (Int -> a) -> Decoder (Driver a)
14driverDecoder unparseNumber =
15    Decode.succeed Driver
16        |> Decode.andMap (Decode.field "name" Decode.string)
17        |> Decode.andMap (Decode.field "number" (Decode.int |> Decode.map unparseNumber))
18

With this approach the front-end can see drivers as containing DriverNumberInputs, whilst the back-end, can ignore that and just use an Int as the type parameter for the driver number, so the front-end defines:

1type alias FrontEndDriver = Driver DriverNumberInput
2
3frontEndEncodeDriver : FrontEndDriver -> Encode.Value
4frontEndEncodeDriver =
5    encodeDriver .number
6
7frontEndDriverDecoder : Decoder FrontEndDriver
8frontEndDriverDecoder =
9    driverDecoder updateIntInput

And the back-end defines:

1type alias BackEndDriver = Driver Int
2
3backEndEncodeDriver : BackEndDriver -> Encode.Value
4backEndEncodeDriver =
5    encodeDriver identity
6
7backEndDriverDecoder : Decoder BackEndDriver
8backEndDriverDecoder =
9    driverDecoder identity

The downside to this approach is that it can get a bit complicated if your data type has different kinds of values that all require different UI states. In particular sometimes you basically want some UI for all inputs, which records whether or not the input has been 'blurred' as this is then used to decide whether or not to display an error message for invalid input.

# Conclusion

At this point, I don't know which of the two are better, but my feeling is that the latter approach is safer.