State is managed using stateful components and hooks. We previously saw how to create a simple state
variable using the state
hook. Here we will take a look at using the reducer
hook to model more
complex state transformations and side effects.
The reducer
hook allows you to model complex state transformations and side effects in a
functional way. At the core of the reducer hook is the update
function which takes the current
model, a message, a dispatcher and returns a new model.
fn update(model: Model, msg: Msg, dispatch: Dispatcher(Msg)) -> Model
The dispatch
function can be used to chain operations by dispatching additional messages to the
reducer or be used by tasks to asynchronously dispatch messages such as fetching data from an API
then updating the state or performing some other side effects.
Make sure that whatever tasks you perform in the update
function do not block the current
execution. Any blocking operations should be performed in a separate task process and the result
dispatched back to the reducer.
Let's create a greeting_button
component as an example. This component will display a button that
when clicked will say hello in a different language. The button will work its way through a list of
greetings and when it reaches the end, it will reset to the beginning.
First we define our state model and message types:
type Model {
Model(selection: Option(Greeting), options: List(Greeting))
}
type Msg {
Greet(Greeting)
Reset
UserClickedGreetingButton(Dynamic)
UserClickedResetButton(Dynamic)
}
Here we're storing a list of options and the currently selected option in the state. When a
UserClickedGreetingButton
message is dispatched, we will randomly select a greeting from the list
of options and update the state with by dispatching a Greet
message with the selected option.
Let's define the init
function:
fn init(options: List(Greeting)) -> fn(Dispatcher(Msg)) -> Model {
fn(_dispatch) { Model(selection: None, options:) }
}
The init
function returns an initializer function that takes a dispatcher and returns the initial
state. In this case, we're initializing the state with a list of options provided. However, this
function could also be used to bootstrap the state by fetching data from an API or performing some
other side effects then dispatching a message to update the state. This function will be given to
the reducer
hook when it is created.
Finally, let's define the update
function:
fn update(model: Model, msg: Msg, dispatch: Dispatcher(Msg)) -> Model {
case msg {
Greet(selection) -> {
let options = model.options |> list.filter(fn(o) { o != selection })
Model(selection: Some(selection), options:)
}
Reset -> init(greetings())(dispatch)
UserClickedGreetingButton(_event) -> {
new_random_selection(model.options, fn(s) { dispatch(Greet(s)) })
model
}
UserClickedResetButton(_event) -> {
dispatch(Reset)
model
}
}
}
The update
function takes the current model, a message, a dispatcher and returns a new model which
represents the updated state. There are four message types that our update function must handle which
are defined in the Msg
type:
1. Greet(Greeting)
: This message updates the state to reflect the new selection by setting the
selected option to the new selection and removing it from the list of options.
2. Reset
: This message resets the state to the initial state. In this case, it uses the same
init
function that will be used to initialize the state when the reducer is created.
3. UserClickedGreetingButton(Dynamic)
: This message is dispatched when the greeting button is
clicked. It calls the new_random_selection
function to select a new greeting from the list of
options and dispatches a Greet
message with the selected option. This is a special type of
message that is used to represent events from the client. The Dynamic
type is used here to
represent the event object that is passed to the event handler. This message can be used in
conjunction with the event_dispatcher
helper to handle client event messages.
4. UserClickedResetButton(Dynamic)
: This message is dispatched when the reset button is clicked.
It dispatches a Reset
message to reset the state to the initial state. It is also a special
type of event message that can be used in conjunction with the event_dispatcher
helper.
Let's declare a reducer hook in our component that initializes the state model and uses our update
function:
use ctx, Model(selection: selection, options: options), dispatch <- reducer(
ctx,
init(greetings()),
update,
)
You can see here we are provided with the current state of the reducer, which we can use in our
component. Notice, we also are provided with a dispatch
function from the reducer. The dispatch
function is used to send messages to the reducer which will update the state and trigger a
re-render.
The second argument init(greetings())
creates an initializer fucntion for the reducer. This
function is called when the component is mounted. In this case, we're initializing the state with a
list of greetings provided by the greetings
function.
type Greeting =
#(String, String)
fn greetings() -> List(Greeting) {
[
#("English", "Hello"),
#("Spanish", "Hola"),
#("French", "Bonjour"),
#("German", "Hallo"),
#("Italian", "Ciao"),
#("Portuguese", "Olá"),
#("Hawaiian", "Aloha"),
#("Chinese (Mandarin)", "你好,(Nǐ hǎo)"),
#("Japanese", "こんにち, (Konnichiwa)"),
#("Korean", "안녕하세, (Annyeonghaseyo)"),
#("Arabic", "مرحب, (Marhaba)"),
#("Hindi", "नमस्त, (Namaste)"),
#("Turkish", "Merhaba"),
#("Dutch", "Hallo"),
#("Swedish", "Hej"),
#("Norwegian", "Hei"),
#("Danish", "Hej"),
#("Greek", "Γεια σας,(Yia sas)"),
#("Polish", "Cześć"),
#("Swahili", "Hujambo"),
]
}
We now have all the pieces we need to create a more interesting button that updates whenever it is clicked. Let's put it all together:
import docs/utils/list.{element_at} as _
import gleam/dynamic.{type Dynamic}
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import sprocket.{type Context, render}
import sprocket/hooks.{type Dispatcher, reducer}
import sprocket/html/attributes.{class}
import sprocket/html/elements.{
button, div, fragment, keyed, span, span_text, text,
}
import sprocket/html/events.{event_dispatcher}
type Model {
Model(selection: Option(Greeting), options: List(Greeting))
}
type Msg {
Greet(Greeting)
Reset
UserClickedGreetingButton(Dynamic)
UserClickedResetButton(Dynamic)
}
fn init(options: List(Greeting)) -> fn(Dispatcher(Msg)) -> Model {
fn(_dispatch) { Model(selection: None, options:) }
}
fn update(model: Model, msg: Msg, dispatch: Dispatcher(Msg)) -> Model {
case msg {
Greet(selection) -> {
let options = model.options |> list.filter(fn(o) { o != selection })
Model(selection: Some(selection), options:)
}
Reset -> init(greetings())(dispatch)
UserClickedGreetingButton(_event) -> {
new_random_selection(model.options, fn(s) { dispatch(Greet(s)) })
model
}
UserClickedResetButton(_event) -> {
dispatch(Reset)
model
}
}
}
fn new_random_selection(options, set_selection) {
let selection =
options
|> list.length()
|> int.random()
|> element_at(options, _, 0)
case selection {
Ok(selection) -> set_selection(selection)
Error(_) -> Nil
}
}
pub type GreetingButtonProps {
GreetingButtonProps
}
pub fn greeting_button(ctx: Context, _props: GreetingButtonProps) {
use ctx, Model(selection:, options:), dispatch <- reducer(
ctx,
init(greetings()),
update,
)
// helper function that allows us to directly dispatch a message from an event
// handler without needing to create a separate function for each event.
use dispatch_event <- event_dispatcher(dispatch)
let num_options_left = list.length(options)
render(
ctx,
div([], [
case options {
[] ->
keyed(
"reset",
button(
[
class(
"p-2 text-blue-500 hover:text-blue-600 hover:underline active:text-blue-700",
),
events.on_click(dispatch_event(UserClickedResetButton)),
],
[text("Reset")],
),
)
_ ->
keyed(
"greet",
button(
[
class(
"p-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded",
),
events.on_click(dispatch_event(UserClickedGreetingButton)),
],
[
text("Say Hello!"),
case num_options_left < 5 {
True ->
span_text(
[class("rounded bg-white text-blue-500 px-1 ml-2")],
int.to_string(num_options_left) <> " left",
)
False -> fragment([])
},
],
),
)
},
..case selection {
None -> []
Some(hello) -> [
span([class("ml-2")], [text(hello.1)]),
span([class("ml-2 text-gray-400 bold")], [text(hello.0)]),
]
}
]),
)
}
type Greeting =
#(String, String)
fn greetings() -> List(Greeting) {
[
#("English", "Hello"),
#("Spanish", "Hola"),
#("French", "Bonjour"),
#("German", "Hallo"),
#("Italian", "Ciao"),
#("Portuguese", "Olá"),
#("Hawaiian", "Aloha"),
#("Chinese (Mandarin)", "你好,(Nǐ hǎo)"),
#("Japanese", "こんにち, (Konnichiwa)"),
#("Korean", "안녕하세, (Annyeonghaseyo)"),
#("Arabic", "مرحب, (Marhaba)"),
#("Hindi", "नमस्त, (Namaste)"),
#("Turkish", "Merhaba"),
#("Dutch", "Hallo"),
#("Swedish", "Hej"),
#("Norwegian", "Hei"),
#("Danish", "Hej"),
#("Greek", "Γεια σας,(Yia sas)"),
#("Polish", "Cześć"),
#("Swahili", "Hujambo"),
]
}
/// Returns the element at the given index in the list
fn element_at(list: List(a), index: Int, start curr: Int) -> Result(a, Nil) {
case list {
[] -> Error(Nil)
[el, ..rest] -> {
case curr == index {
True -> Ok(el)
False -> element_at(rest, index, curr + 1)
}
}
}
}
We now have a functional button that says hello in a different language when it's clicked.
Remember, all of these state changes are happening on the server. Events are being passed from the client to the server, the latest view is rendered and a minimal diff is sent back to the client a which is then patched into the DOM!
These are just two of the hooks that are available in Sprocket. There are many more to explore! We'll cover hooks more in-depth in the next section.