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. We'll also utilize the handler
hook to help us
dispatch events to the reducer.
The update
function is the core of the reducer
hook. It allows you to model complex state
transformations and side effects in a functional way. The update
function is called whenever a
message is dispatched to the reducer. This function is responsible for updating the state and
specifying any side effect commands to be executed.
The update
function has the following signature:
fn update(model: Model, msg: Msg) -> #(Model, List(Cmd(Msg)))
The update
function takes the current state and a message and returns a new state and a list of
commands to be executed. Model
is the state model type and Msg
is the message type.
For simple model updates, the list of commands will typically be empty. However, commands can be a powerful tool for performing side effects such as making HTTP requests, updating local storage, or chaining operations by dispatching additional messages.
Let's create a greeting_button
component as an example, we can
define a function that updates the state when the button is clicked.
First we define our state model and message types:
type Model {
Model(selection: Option(Greeting), options: List(Greeting))
}
type Msg {
NoOp
NextGreeting
Greet(Greeting)
Reset
}
Here we're storing a list of options and the currently selected option in the state. When we
dispatch a NextGreeting
message, we'll randomly select a new option from the list using a
command called new_random_selection
.
Let's define the update
function:
fn update(model: Model, msg: Msg) {
case msg {
NoOp -> #(model, [])
NextGreeting -> #(model, [new_random_selection(model.options)])
Greet(selection) -> {
let options = model.options |> list.filter(fn(o) { o != selection })
#(Model(selection: Some(selection), options:), [])
}
Reset -> init(greetings())
}
}
The update
function takes the current state and a message and returns a new state and a list of
commands to be executed. In this case, we're handling four different messages: NoOp
,
NextGreeting
, Greet
, and Reset
.
The NoOp
message does nothing and is just here for illustrative purposes.
The NextGreeting
message dispatches a new random selection
The Greet
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.
The Reset
message resets the state to the initial state. It uses the same init
function that
will be used to initialize the state when the component is mounted.
Let's define the init
function:
fn init(options: List(Greeting)) -> #(Model, List(Cmd(Msg))) {
#(Model(selection: None, options:), [])
}
The init
function initializes the state by returning the initial model and, in this case, an
empty list of commands. However, commands can be useful here to perform any side effects required
to bootstrap the state when the component is mounted.
Finally, let's define the new_random_selection
command function:
fn new_random_selection(options) -> Cmd(Msg) {
fn(dispatch) {
let selection =
options
|> list.length()
|> int.random()
|> element_at(options, _, 0)
case selection {
Ok(selection) -> dispatch(Greet(selection))
Error(_) -> Nil
}
}
}
The new_random_selection
function takes a list of options and returns a command that will dispatch
a Greet
message with a randomly selected option from the list. If the selection fails, it will
simply return Nil
.
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())
, initializes the state of the reducer. The init
function is called when the component is mounted. In this case, we're initializing the state with a
list of options using 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 need one more thing to complete our component. We need to define a function that will be called
when the button is clicked. It's important that we create an IdentifiableHandler
by using the
handler
hook so that we can ensure the id of the handler function is consistent across renders,
preventing a new id being created and sent to the client on every render.
Let's define both the say_hello
and reset
handlers:
use ctx, say_hello <- handler(ctx, fn(_) { dispatch(NextGreeting) })
use ctx, reset <- handler(ctx, fn(_) { dispatch(Reset) })
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 gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import sprocket/component.{render}
import sprocket/context.{type Context}
import sprocket/hooks.{type Cmd, handler, reducer}
import sprocket/html/attributes.{class}
import sprocket/html/elements.{button, div, keyed, span, text}
import sprocket/html/events
type Model {
Model(selection: Option(Greeting), options: List(Greeting))
}
type Msg {
NoOp
NextGreeting
Greet(Greeting)
Reset
}
fn init(options: List(Greeting)) -> #(Model, List(Cmd(Msg))) {
#(Model(selection: None, options:), [])
}
fn update(model: Model, msg: Msg) {
case msg {
NoOp -> #(model, [])
NextGreeting -> #(model, [new_random_selection(model.options)])
Greet(selection) -> {
let options = model.options |> list.filter(fn(o) { o != selection })
#(Model(selection: Some(selection), options:), [])
}
Reset -> init(greetings())
}
}
fn new_random_selection(options) -> Cmd(Msg) {
fn(dispatch) {
let selection =
options
|> list.length()
|> int.random()
|> element_at(options, _, 0)
case selection {
Ok(selection) -> dispatch(Greet(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,
)
use ctx, say_hello <- handler(ctx, fn(_) { dispatch(NextGreeting) })
use ctx, reset <- handler(ctx, fn(_) { dispatch(Reset) })
let num_options_left = list.length(options)
render(
ctx,
div([], [
case options {
[] ->
button(
[
class(
"p-2 text-blue-500 hover:text-blue-600 hover:underline active:text-blue-700",
),
events.on_click(reset),
],
[text("Reset")],
)
_ ->
button(
[
class(
"p-2 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white rounded",
),
events.on_click(say_hello),
],
[
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.