Skip to content

Instantly share code, notes, and snippets.

@hayleigh-dot-dev
Created June 22, 2020 23:08
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save hayleigh-dot-dev/0ef81fa130bcbd5d1307efd1df5824bd to your computer and use it in GitHub Desktop.
Save hayleigh-dot-dev/0ef81fa130bcbd5d1307efd1df5824bd to your computer and use it in GitHub Desktop.
module Limiter exposing
( Limiter, Msg
, debounce, throttle
, event, push
, update
)
{-| A Limiter is a handy way of slowing down the number of messages or events
we get from a particular source. We have two main methods of rate-limiting this
data:
- Debouncing
- Throttling
A debouncer will wait a short delay after a burst of inputs and then emit the
most recent message. On the other hand, a throttler will typically emit the
first message from a burst and then emit a second message after a fixed time
period has passed, and then a third after that time period again and so on.
You can visualise the difference like so:
- Debouncing
```
--a-b-cd--e----------f--------
---------------e----------f---
```
- Throttling
```
--a-b-cd--e----------f--------
--a---c---e----------f--------
```
There are also two ways to limit messages with this module. We can limit messages
directly from a HTML event or we can manually push messages into the Limiter,
typically inside our `update` function. Which approach you take is typically
decided on whether you need to save some information from the event directly.
If the answer is no, as is the case with click events on buttons and similar
elements, then limiting at the source of the event using `Limiter.event` is
the cleanest solution. If the answer is yes, as is potentially the case with
text inputs, then you're likely to want `Limiter.push` so you can save the
raw input text in real-time and rate-limit some reaction to those events.
A simple but complete example demonstrating how both types on limiter and
both approaches to limiting can be used is found at the bottom of this page,
after the documentation.
# The Limiter type
@docs Limiter, Msg
# Constructing a Limiter
@docs debounce, throttle
# Adding events to a Limiter
@docs event, push
# Updating a Limiter
@docs update
## A Complete Example
module Main exposing (main)
import Browser
import Html
import Html.Attributes
import Html.Events
import Styleguide.Limiter as Limiter exposing (Limiter)
main : Program () Model Msg
main =
Browser.element
{ init = init
, update = update
, view = view
, subscriptions = always Sub.none
}
type alias Model =
{ counter : Int
, rawInput : String
, debouncer : Limiter Msg
, throttler : Limiter Msg
}
init : () -> ( Model, Cmd Msg )
init _ =
( { counter = 0
, rawInput = ""
, debouncer = Limiter.debounce DebounceMsg 500
, throttler = Limiter.throttle ThrottleMsg 100
}
, Cmd.none
)
type Msg
= Increment
| GotInput String
| SearchFor String
| DebounceMsg (Limiter.Msg Msg)
| ThrottleMsg (Limiter.Msg Msg)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg modl =
case msg of
Increment ->
( { model | counter = model.counter + 1 }
, Cmd.none
)
GotInput input ->
Limiter.push (SearchFor input) model.throttler
|> Tuple.mapFirst
(\throttler ->
{ model
| throttler = throttler
, rawInput = input
}
}
)
SearchFor input ->
( model
, searchBackndFor input
)
DebounceMsg debounceMsg ->
Limiter.update debounceMsg model.debouncer
|> Tuple.mapFirst (\debouncer -> { model | debouncer = debouncer })
ThrottlerMsg throttleMsg ->
Limiter.update throttleMsg model.throtther
|> Tuple.mapFirst (\throttler -> { model | throttler = throttler })
view : Model -> Html Msg
view model =
Html.div []
[ Html.div []
[ Html.button
[ Html.Events.onClick (Limiter.event Increment model.debouncer) ]
[ Html.text "+" ]
, Html.p []
[ Html.text <| String.fromInt model.counter ]
]
, Html.div []
[ Html.span []
[ Html.text "Search for: " ]
, Html.input
[ Html.Events.onInput GotInput
, Html.Attributes.value model.rawInpt
] []
]
]
-}
import Process
import Task
{-| The `Limiter` type wraps up all the data necessary to properly rate-limit
incoming messages. You will need a separate Limiter for every event/message
you want to limit (unless you want to share the same timing across different
events).
This means you'll need to store each Limiter in your model.
-}
type Limiter msg
= Limiter
{ tagger : Msg msg -> msg
, mode : Mode msg
, state : State
}
{-| The `Debounce` mode keeps track of how many messages it's received in a
particular burst. Every message added to the list schedules a check some time
in the future; if the list hasn't changed in that time we emit the newest
message in the list and discard the rest.
The `Throttle` mode just needs to keep track of what interval to throttle
messages at.
Both modes expect the time to be in _milliseconds_.
-}
type Mode msg
= Debounce Int (List msg)
| Throttle Int
{-| -}
type State
= Open
| Closed
{-| A type for messages internal to the Limiter. Notice how it is parameterised
by the `msg` type you want to rate-limit (although this could be any time, not
just a message).
These just get passed into the Limiter in your `update` function.
-}
type Msg msg
= Emit msg
| EmitIfSettled Int
| None
| Reopen
| Push msg
-- Creating a Limiter
{-| A debouncer limits messages by waiting for a burst of messages to settle
before emitting the most recent message. This means they'll always be a brief
delay even if only one message is received; this is demonstrated below.
--a-b-cd--e----------f--------
---------------e----------f---
To construct a debouncer you need to pass in a "tagger" function that will wrap
the Limiter's internal `Msg` type. You also need to supply the cooldown time
in milliseconds which is the delay between the last message in a burst being
sent and that message being emitted.
-}
debounce : (Msg msg -> msg) -> Int -> Limiter msg
debounce tagger cooldown =
Limiter
{ tagger = tagger
, mode = Debounce cooldown []
, state = Open
}
{-| A throttler limits messages by only alowwing messages to come in as fast
as a fixed interval allows. When receive a burst of messages, the first one
will pass through the emitter and then all messages are ignored for a period of
time, then the next message will pass through and so on.
--a-b-cd--e----------f--------
--a---c---e----------f--------
To construct a debouncer you need to pass in a "tagger" function that will wrap
the Limiter's internal `Msg` type. You also need to supply the interval time in
milliseconds, which is the minimum amount of time that must pass before
consecutive messages can be emitted.
-}
throttle : (Msg msg -> msg) -> Int -> Limiter msg
throttle tagger interval =
Limiter
{ tagger = tagger
, mode = Throttle interval
, state = Open
}
-- Adding messages to a Limiter
{-| An obvious condidates for rate-limiting events are buttons. We can use
`Limiter.event` to limit these events right at the source, in the HTML.
For example, we may have a pagination component that sends out a HTTP request
every time the page changes to fetch new content. We want to prevent a sudden
burst of requests being sent if the user clicks the "next page" button multiple
times so we create a debouncer and use that to limit the number of events that
will be produced by the HTML.
As a rule of thumb, if you don't need any data from the event itself then
`Limiter.event` is a good choice to avoid cluttering up the `update` function
with needless logic.
Html.button
[ Html.Events.onClick (Limiter.event Increment model.debouncer) ]
[ Html.text "+" ]
-}
event : msg -> Limiter msg -> msg
event msg (Limiter { tagger, mode, state }) =
case ( state, mode ) of
( Open, Debounce _ _ ) ->
tagger (Push msg)
( Open, Throttle _ ) ->
tagger (Emit msg)
( Closed, _ ) ->
tagger None
{-| Sometimes we don't want to limit events coming from the HTML, but we want
to limit how often we perform an action based on that event instead. A typical
example is searching for things on the backend in realtime as the user types.
We don't want to send a HTTP request on each keypress, but we also don't want
to limit the events coming from the HTML otherwise we'll lose the user's input.
In these cases we use `Limiter.push` to manually push messages into the
Limiter.
What we get back is a tuple with a new Limiter and a `Cmd` that will immediately
resolve if the message we pushed in was allowed through.
As a rule of thumb, if you _do_ need to capture some data from the event source
then `Limiter.push` will allow you to store that data before rate-limiting some
additional message in response.
update msg model =
case msg of
GotInput input ->
Limiter.push (SearchFor input) model.throttler
|> Tuple.mapFirst
(\throttler ->
{ model
| throttler = throttler
, input = input
}
)
SearchFor input ->
( model
, Http.get
{ ...
}
)
-}
push : msg -> Limiter msg -> ( Limiter msg, Cmd msg )
push msg (Limiter ({ tagger, mode, state } as limiter)) =
case ( state, mode ) of
( Open, Debounce cooldown queue ) ->
( Limiter { limiter | mode = Debounce cooldown (msg :: queue) }
, emitAfter cooldown (tagger <| EmitIfSettled (List.length queue + 1))
)
( Open, Throttle interval ) ->
( Limiter { limiter | state = Closed }
, Cmd.batch
[ emitAfter interval (tagger Reopen)
, emit msg
]
)
( Closed, _ ) ->
( Limiter limiter
, Cmd.none
)
-- Updating a Limiter
{-| Limiters work by producing their own `Msg` values and using them to update
the internal state of the Limiter. When you construct a Limiter you have to
provide a "tagger" function that wraps these internal messages into a type
that your own update function can deal with.
You don't need to do much with this function, just ensure that you're calling
it in your update function whenever you get a wrapper message:
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
...
LimiterMsg limiterMsg ->
Limiter.update limiterMsg model.limiter
|> Tuple.mapFirst (\limiter -> { model | limiter = limiter })
The update function produces Cmds like any other update function might, and this
is how the messages we care about make their way into our application. You will
need to repeat the little bit of code above for every Limiter you have in an
application.
-}
update : Msg msg -> Limiter msg -> ( Limiter msg, Cmd msg )
update internalMsg (Limiter ({ tagger, mode, state } as limiter)) =
case ( internalMsg, state, mode ) of
( Emit msg, Open, Throttle interval ) ->
( Limiter { limiter | state = Closed }
, Cmd.batch
[ emitAfter interval (tagger Reopen)
, emit msg
]
)
( EmitIfSettled msgCount, Open, Debounce cooldown queue ) ->
if List.length queue == msgCount then
( Limiter { limiter | mode = Debounce cooldown [] }
, List.head queue
|> Maybe.map emit
|> Maybe.withDefault Cmd.none
)
else
( Limiter limiter
, Cmd.none
)
( Reopen, _, _ ) ->
( Limiter { limiter | state = Open }
, Cmd.none
)
( Push msg, Open, Debounce cooldown queue ) ->
( Limiter { limiter | mode = Debounce cooldown (msg :: queue) }
, emitAfter cooldown (tagger <| EmitIfSettled (List.length queue + 1))
)
_ ->
( Limiter limiter
, Cmd.none
)
-- Emitting messages
{-| Take a msg and turn it into a command that is resolved after a specified
delay.
-}
emitAfter : Int -> msg -> Cmd msg
emitAfter delay msg =
Basics.toFloat delay
|> Process.sleep
|> Task.perform (always msg)
{-| Take a msg and turn it into a command that is immediately resolved. This
should result in a single render happening between the call to `emit` and
receiving the actual msg.
There is no way to combat this, as commands are asynchronous by nature.
-}
emit : msg -> Cmd msg
emit msg =
Task.succeed msg
|> Task.perform identity
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment