Hooks are the essential mechanism that enable components to implement stateful logic, produce and consume side-effects, and couple a component to it's hierarchical context within the UI tree. They also make it easy to abstract and reuse stateful logic across different components.
Fundamentally, hooks are just higher order functions that are called from a stateful component which
take the current context (and possibly other values) and provide a list of variables that can be
used within the component. Internally, these function's can 'hook' into the component's lifecycle
using this context and create the basis for stateful logic. For example, the state
hook provides a
state variable and a setter function.
pub fn example(ctx: Context, _props: ExampleProps) {
use ctx, count, set_count <- state(ctx, 0)
render(
ctx,
div([], [
text("The current state is " <> int.to_string(count)),
])
)
}
Understanding the use
keyword and how higher order functions work in Gleam is necessary to fully
understand the hook syntax. Check out the official Gleam documentation on use
expressions for more information.
Note Hooks must be called in exactly the same order on every render and should be defined at the top of a component body. This means hooks cannot be called conditionally or within loops or nested functions.
Sprocket provides a common set of native hooks that can be imported from the sprocket/hooks
module.
import sprocket/hooks.{state, reducer, handler, effect, memo, callback, channel, provider, client}
We'll go over each of the native hooks and how to use them and also cover how to create custom hooks.
State hooks are used to manage a piece of state within a component. The current state along with a setter function are provided to the component. State is initialized to the value provided and can be updated by calling the setter function with the new value. State is maintained across renders but is reinitialized when a component is unmounted and remounted.
use ctx, count, set_count <- state(ctx, 0)
Reducer hooks are used to manage more complex state. Similar to a state hook, a reducer will maintain the state across renders and be reinitialized when a component is mounted. However, a reducer is better for when state changes require complex transforms to a state model or when state logic needs to be abstracted out of a component module.
Under the hood, a reducer hook is a lightweight Gleam Actor (i.e. OTP process, like gen_server) and changes to the state (messages sent via dispatch) result in a re-render of the view.
For when an Elm or Redux style architecture is preferred, a reducer hook should be used.
type Model =
Int
type Msg {
Increment
Decrement
SetCount(Int)
Reset
}
fn update(_model: Model, msg: Msg) -> #(Model, List(Cmd(Msg))) {
case msg {
Increment -> {
#(model + 1, [])
}
Decrement -> {
#(model - 1, [])
}
SetCount(count) -> #(count, [])
Reset -> #(0, [])
}
}
The current model along with a dispatch function are provided. The model is initialized to the value provided and can be updated by calling the dispatch function with a message.
use ctx, count, dispatch <- reducer(ctx, 0, update)
Reducer hooks allow state management to be refactored out of the component file and into a separate module. This can be useful for complex state management logic or message types that are shared across multiple components.
Handler hooks are used to create event handlers, They take a function and return an IdentifiableCallback. The IdentifiableCallback can be passed to an event handler attribute and ensures that event id's do not change across renders resulting in unnecessary diff patching. The callback will be called when the event is triggered and provide an optional CallbackParam depending on the event type.
use ctx, increment <- handler(ctx, fn(_) {
dispatch(Increment)
})
Effect hooks are used to perform side-effects. They take a function that is always called once on
mount and subsequently whenever any dependency in the specified list of dependencies change. They
can also specify an optional cleanup function as a return value which will be called when the
component is unmounted. This is useful for managing subscriptions, timers, or other side-effects.
All dependencies must be converted to a dependency type using the dep
function. This is necessary
for the homogeneous list.
To only run the effect function on mount, provide an empty list of dependencies.
To run the effect function on every render, provide a single-element list with the Context: [dep(ctx)]
.
use ctx <- effect(
ctx,
fn(_) {
// Perform side-effects here
// Return a cleanup function if necessary
Some(fn(_) {
// Cleanup side-effects here
})
},
[dep(some_value)],
)
The effect
hook takes a list of dependencies that will cause the effect function
to be called again when any of the dependencies change. If an empty list is provided, the effect
function will only be called on mount.
Memo hooks are used to memoize a computed value. They take a function and a list of dependencies and return a memoized value. The memoized value will only be re-evaluated when the dependencies change.
use ctx, memoized_value <- memo(ctx, fn() { expensive_fn() }, [dep(some_value)])
Callback hooks are used to memoize a function. They take a function and a list of dependencies and return a memoized function. The memoized function will only be re-evaluated when the dependencies change.
use ctx, memoized_fn <- callback(ctx, some_fn, [dep(some_value)])
Provider hooks are used to access some data from a parent or ancestor component in the UI tree. The hook takes a provider key and returns the value provided by the ancestor. It is useful for providing global state or some slice of data to a component without having to pass it down through the component tree, sometimes known as "prop drilling".
This hook is conceptually similar to the useContext
hook in React.
use ctx, current_user <- provider(ctx, "user")
Because the provider key is a global identifier, it is important to use a unique key to avoid collisions with other providers. The key should be a string that is unique to the data being provided and if used in a library then it is recommened that it be namespaced to avoid conflicts with other keys within the application.
Client hooks are a special type of hook that enable a component to implement logic on the client.
We can expand the display
component to accept another optional prop called on_reset
which will reset
the count and re-render the component when the display
component is double-clicked.
pub type DisplayProps {
DisplayProps(count: Int, on_reset: Option(fn() -> Nil))
}
pub fn display(ctx: Context, props: DisplayProps) {
let DisplayProps(count: count, on_reset: on_reset) = props
use ctx, client_doubleclick, _dispatch_client_doubleclick <- client(
ctx,
"DoubleClick",
Some(fn(msg, _payload, _dispatch) {
case msg {
"doubleclick" -> {
case on_reset {
Some(on_reset) -> on_reset()
None -> Nil
}
}
_ -> Nil
}
}),
)
render(
ctx,
span(
[
client_doubleclick(),
class(
"p-1 px-2 w-10 bg-white dark:bg-gray-900 border-t border-b dark:border-gray-500 align-center text-center",
),
],
[text(int.to_string(count))],
),
)
}
We also need to implement some JavaScript to handle the double-click event on the client and send a message to the server.
import { connect } from 'sprocket-js';
const hooks = {
DoubleClick: {
create({ el, pushEvent }) {
el.addEventListener('dblclick', () => {
pushEvent('doubleclick', {});
});
},
},
};
...
connect(livePath, {
csrfToken,
hooks,
});
Hooks can be combined to create custom hooks. For example, we can refactor our doubleclick client hook logic to create a reusable custom hook.
pub fn doubleclick(ctx: Context, on_doubleclick: fn() -> Nil, cb) {
use ctx, client_doubleclick, _dispatch_client_doubleclick <- client(
ctx,
"DoubleClick",
Some(fn(msg, _payload, _dispatch) {
case msg {
"doubleclick" -> {
on_doubleclick()
}
_ -> Nil
}
}),
)
cb(ctx, client_doubleclick)
}
To use the doubleclick
hook, we can call it within a component as we normally would a native hook
pub fn display(ctx: Context, props: DisplayProps) {
let DisplayProps(count: count, on_reset: on_reset) = props
use ctx, reset_on_doubleclick <- doubleclick(ctx, fn() { dispatch(Reset) })
render(
ctx,
span(
[
reset_on_doubleclick(),
class(
"p-1 px-2 w-10 bg-white dark:bg-gray-900 border-t border-b dark:border-gray-500 align-center text-center",
),
],
[text(int.to_string(count))],
),
)
}