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
}
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
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
}
After:
type alias Fields =
{ username : DebouncedField Username
, email : Field Email
, password : Field Password
, passwordConfirmation : Field PasswordConfirmation
}
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
}
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."
]
]
]
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.