Hooks
Hooks give a component local state, side effects, memoized values, and access to the DOM. They’re available through a Hooks context that Riposte supplies while rendering — which is exactly…
Hooks give a component local state, side effects, memoized values, and access to the DOM.
They’re available through a Hooks context that Riposte supplies while rendering — which
is exactly what the component constructors (view, component, container) set up. Call
hooks at the top level of a component body and you never pass the context explicitly:
import io.github.edadma.riposte.*
val Greeting = view {
val (name, setName, _) = useState("world")
div(
input(value := name, onInput := (e => setName(targetValue(e)))),
p(s"Hello, $name!"),
)
}
The same is true inside component[P] { props => … } and container { children => … } —
all three run their body with the Hooks context in scope.
The rules of hooks
Hooks are positional: Riposte identifies each one by its call order within a component, not by name. Two rules follow, and they’re the same as React’s:
- Call hooks unconditionally, in the same order every render. Never put a hook inside
an
if, a loop, or a nested function — that would shift the positions on some renders and scramble the state. Put the condition inside the hook instead. - Call hooks only from a component body (or from another hook), not from ordinary functions.
// Wrong — the hook is conditional:
if loggedIn then val (x, setX, _) = useState(0)
// Right — the hook always runs; the condition lives in how you use it:
val (x, setX, _) = useState(0)
if loggedIn then …use x…
useState
useState(initial) returns a triple: the current value, a set that replaces it, and an
update that derives the next value from the previous one.
val (count, set, update) = useState(0)
button(onClick := (_ => update(_ + 1)), s"Count: $count") // increment
button(onClick := (_ => set(0)), "Reset") // replace
Reach for update whenever the next value depends on the current one — it composes
correctly when several updates happen in the same event, where repeated set(count + 1)
calls would all see the same stale count. Setters schedule a re-render on the microtask
queue, so multiple updates in one event handler are batched into a single render.
The state can be any type — a Vector, a case class, whatever:
val (items, setItems, updateItems) = useState(Vector.empty[String])
updateItems(_ :+ "new")
useReducer
For state with several distinct transitions, useReducer centralizes them in one
function. It returns the current state and a dispatch:
enum Action:
case Increment, Decrement, Reset
val (count, dispatch) = useReducer[Int, Action](
(state, action) => action match
case Action.Increment => state + 1
case Action.Decrement => state - 1
case Action.Reset => 0,
0,
)
button(onClick := (_ => dispatch(Action.Increment)), s"Count: $count")
useEffect
useEffect(body, deps) runs a side effect after the DOM has been updated and the
browser has painted (it runs on the macrotask queue, so it never blocks a frame). Use it
for anything outside the render-to-DOM flow: network requests, subscriptions, timers,
manual DOM measurement, logging.
The body returns a cleanup — either a function that undoes the effect, or noCleanup
when there’s nothing to tear down. Riposte runs the cleanup before the effect re-runs and
once more on unmount:
useEffect(
() => {
val id = dom.window.setInterval(() => tick(), 1000)
() => dom.window.clearInterval(id) // cleanup
},
Array(), // deps
)
The dependency array controls when the effect re-runs:
Array(a, b)— re-run only whenaorbchanged since the last render.Array()(empty) — run once, after the first mount, and clean up on unmount.null— run after every render.
useEffect(
() => { dom.document.title = s"Count: $count"; noCleanup },
Array(count),
)
useLayoutEffect has the same signature but runs synchronously after DOM mutations and
before paint — use it (sparingly) when you must read layout and re-mutate the DOM
without the user seeing an intermediate frame.
useRef
A ref is a mutable box whose .current survives across renders without causing a
re-render when you change it. Two uses:
A handle to a DOM node. Bind it with the ref attribute, then reach for the node
imperatively — the thing state can’t do:
val inputRef = useRef[dom.html.Input | Null](null)
div(
input(ref := inputRef, placeholder := "press focus →"),
button(
onClick := { _ =>
val node = inputRef.current
if node != null then node.focus()
},
"focus",
),
)
ref also accepts a callback — ref := (node => …) — invoked with the node on mount and
null on unmount, for when you need to react to attach/detach.
Mutable instance state that isn’t UI. A ref is the place for a value you want to remember but that shouldn’t trigger rendering — a previous value, a timer id, a flag.
useMemo and useCallback
Both cache something across renders, keyed on a dependency array, to avoid redundant work.
useMemo(compute, deps) caches the result of an expensive computation, recomputing only
when a dependency changes:
val sorted = useMemo(() => items.sortBy(_.name), Array(items))
useCallback(fn, deps) caches a function value so its identity is stable across renders
— important when you pass a handler to a memo-ized child (an inline lambda would be a new
object each render and defeat the memoization) or use it as an effect dependency:
val onSelect = useCallback((id: String) => setSelected(id), Array())
Row(rowProps, onSelect) // Row can now actually bail out
useId
useId() returns a stable, unique string id — the same value across re-renders of that
instance. Use it to wire an input to its label without hand-managing globally unique ids:
val id = useId()
label(htmlFor := id, "Email")
input(id := id, `type` := "email")
useContext
Context passes a value down to descendants without threading it through every component’s
props. Create a context with a default, provide a value to a subtree, and read the nearest
provided value with useContext:
val ThemeContext = createContext("light")
val Themed = view {
val theme = useContext(ThemeContext)
div(cls := s"theme-$theme", "…")
}
// Provide a value to a subtree:
ThemeContext.provide("dark", Themed())
Reading a context subscribes the component to it: when a provider higher up supplies a new value, every descendant that reads the context re-renders. The atoms and router modules are both built on context.
useTransition
useTransition(target, durationMs) eases a Double toward target over durationMs,
re-rendering the component each animation frame until it settles. It’s a self-driving
animation primitive for simple numeric transitions — progress bars, fades, slides:
val (open, _, toggle) = useState(false)
val width = useTransition(if open then 100.0 else 0.0, 400)
div(
css("width" -> s"$width%", "height" -> "0.5rem", "background" -> "#0c8599"),
// a button elsewhere calls toggle(o => !o)
)
useSyncExternalStore
The low-level seam for subscribing a component to an external, mutable data source. You
give it a subscribe function (called with a callback to run on change; returns an
unsubscribe) and a getSnapshot that reads the current value:
val value = useSyncExternalStore(
subscribe = cb => { source.addListener(cb); () => source.removeListener(cb) },
getSnapshot = () => source.current,
)
You rarely call this directly — it’s the foundation the atoms module and
the router’s location tracking are built on. Reach for those higher-level APIs first;
useSyncExternalStore is here for integrating a store Riposte doesn’t already wrap.