Diary of an Elm Developer - Exploring debounced input
Dwayne Crooks

Dwayne Crooks @dwayne

About: Learn to build reliable web applications with Elm.

Location:
Trinidad & Tobago
Joined:
Apr 29, 2019

Diary of an Elm Developer - Exploring debounced input

Publish Date: Jul 3
0 0

Debouncing in general is about taking control of when a given action is performed.

If a user is entering their username and you want to check if their username is available via an API call then you don't want to make that check everytime they type a letter. You want to give them ample time to enter their username before making the check. Debouncing allows you to control when you make that check.

There are many more use cases for debouncing and a related idea called throttling which you can explore further in elm-debouncer Examples.

Today I'm going to share the work I'm doing to integrate debouncing (dwayne/elm-debouncer) with form fields (not yet released).

Lib.DebouncedField

The idea that came naturally to me was to bundle the field and the debouncer and create a new type of field called a DebouncedField.

type DebouncedField e a
    = DebouncedField (State e a)


type alias State e a =
    { field : Field e a
    , ready : Bool
    , debouncer : Debouncer String
    }
Enter fullscreen mode Exit fullscreen mode

I encountered several problems doing it this way.

Firstly, a debounced field isn't really a field. It contains a field but it's not a field. To operate on the field I had to provide specific functions for working on the field. After seeing how many functions I needed in order to reclaim unfettered access to the field I caved and decided I needed the following API:

fromField : Field e a -> DebouncedField e a
changeField : (Field e a -> Field e a) -> DebouncedField e a -> DebouncedField e a
toField : DebouncedField e a -> Field e a
Enter fullscreen mode Exit fullscreen mode

With that API, DebouncedField has access to the field to do what it needs and you have access to do what you need. A win-win. However, the nagging issue became which field is going to be the source of truth. I decided to let it slide for the time being.

But I couldn't overlook the following change to my signup form.

Before:

type alias Fields =
    { username : Field Username
    , email : Field Email
    , password : Field Password
    , passwordConfirmation : Field PasswordConfirmation
    }
Enter fullscreen mode Exit fullscreen mode

After:

type alias Fields =
    { username : DebouncedField Username
    , email : Field Email
    , password : Field Password
    , passwordConfirmation : Field PasswordConfirmation
    }
Enter fullscreen mode Exit fullscreen mode

N.B. The error type e has been specialized to String and elided by using a type alias in these examples.

What that meant was that every function in my sign up form module that touched username had to be changed.

You may be wondering why that's such a huge problem. So let me explain.

The sign up form module without DebouncedField captures all the requirements of the sign up form regardless of how you eventually choose to display it. That's fantastic because it means we can write form modules that capture the data requirements of all our forms and reuse them over several applications and render them differently in each one. So currently the form logic is independent of the way the form looks and independent of how you will be able to interact with it.

When I made username a DebouncedField all that went out the window because I was committing to the username field being debounced when rendered. At the same time I was also committing to email, password, and passwordConfirmation not being debounced now and in the future.

A good architecture makes the system easy to change, in all ways that it must change, by leaving options open.

A good architect will want to separate the UI portions of a use case from the business rule portions in such a way that they can be changed independently of each other, while keeping those use cases visible and clear.

The business rules should be the most independent and reusable code in the system.

Depend on things that have few to no reasons to change.

Clean Architecture: A Craftsman's Guide to Software Structure and Design
by Robert C. Martin

What I've come to realize in hindsight is that deboucing is a UI concern. My sign up form module captures the business rules of the application. As a result, my sign up form module should NEVER depend on whether or not you decide you want to debounce the username field.

Enter Lib.DebouncedInput

What I want instead is a reusable view for rendering a field whose input I want to debounce. My sign up form remains unchanged and reusable. Great! And, I get to decide when I'm building the UI if I want to add debouncing to any of the fields.

type DebouncedInput
    = DebouncedInput State


type alias State =
    { debouncer : Debouncer String
    , ready : Bool
    }
Enter fullscreen mode Exit fullscreen mode

Here's how I use it in a deboucing example:

type alias Model =
    { status : Status
    , username : Field Username
    , usernameDebouncedInput : DebouncedInput
    , timer : Timer
    }

init : () -> ( Model, Cmd msg )
init _ =
    ( { status = Normal
      , username = F.empty Username.fieldType
      , usernameDebouncedInput = DebouncedInput.init
      , timer = Timer.init
      }
    , Cmd.none
    )

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        InputUsername s ->
            ( { model
                | status = Normal
                , username = F.setFromString s model.username
                , timer =
                    case model.status of
                        Checking _ ->
                            Timer.cancel model.timer

                        _ ->
                            model.timer
              }
            , Cmd.none
            )

        ReadyUsername s ->
            let
                usernameField =
                    F.setFromString s model.username

                ( timer, cmd ) =
                    Timer.setTimeout timerConfig model.timer

                newModel =
                    { model | username = usernameField, timer = timer }
            in
            ( case F.toMaybe usernameField of
                Just username ->
                    { newModel | status = Checking username }

                Nothing ->
                    newModel
            , cmd
            )

        FocusUsername ->
            ( model, Cmd.none )

        ChangedUsernameDebouncedInput subMsg ->
            let
                ( usernameDebouncedInput, cmd ) =
                    DebouncedInput.update usernameDebouncedInputConfig subMsg model.usernameDebouncedInput
            in
            ( { model | usernameDebouncedInput = usernameDebouncedInput }
            , cmd
            )

        TimerExpired ->
            ( model
            , Random.generate GotResult <|
                Random.weighted
                    ( 1, False )
                    [ ( 3, True )
                    ]
            )

        ChangedTimer timerMsg ->
            ( model
            , Timer.update timerConfig timerMsg model.timer
            )

        GotResult isFree ->
            case model.status of
                Checking _ ->
                    ( { model
                        | status =
                            if isFree then
                                Success

                            else
                                Error
                      }
                    , focus "username" FocusUsername
                    )

                _ ->
                    ( model, Cmd.none )

view : Model -> H.Html Msg
view model =
    H.form [ HA.style "margin" "10px" ]
        [ H.label [ HA.for "username" ] [ H.text "Username: " ]
        , DebouncedInput.view
            { field = model.username
            , debouncedInput = model.usernameDebouncedInput
            , isRequired = True
            , isDisabled =
                case model.status of
                    Checking _ ->
                        True

                    _ ->
                        False
            , config = usernameDebouncedInputConfig
            , attrs =
                [ HA.autofocus True
                , HA.id "username"
                ]
            }
        , H.p []
            [ case model.status of
                Normal ->
                    H.text ""

                Checking username ->
                    H.em
                        []
                        [ H.text <| "Checking if \"" ++ Username.toString username ++ "\" is free..."
                        ]

                Success ->
                    H.span
                        [ HA.style "color" "green" ]
                        [ H.text "The username is available."
                        ]

                Error ->
                    H.span
                        [ HA.style "color" "red" ]
                        [ H.text "The username is already taken."
                        ]
            ]
        ]
Enter fullscreen mode Exit fullscreen mode

usernameDebouncedInput tracks the state required for debouncing. It is decoupled from the field, explicit in the code, and doesn't need to be there if later on we decide we want to remove debouncing.

Conclusion

The outcome of this exploration is that debouncing is a UI concern and as a result it will not be present in my field library. I will probably have to write a separate library for a reusable debounced input view.

Further reading

Subscribe to my newsletter

If you're interested in improving your skills with Elm then I invite you to subscribe to my newsletter, Elm with Dwayne. To learn more about it, please read this announcement.

Comments 0 total

    Add comment