State Management

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.

Introducing the Reducer Hook

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.

Greeting Button Example

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.

The Reducer Hook in Action

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"),
  ]
}

Putting it all together

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.