Riposte

State with Atoms

useState is enough for state owned by one component. When state needs to be shared across components that aren’t in a simple parent-child line — and you’d rather not thread…

useState is enough for state owned by one component. When state needs to be shared across components that aren’t in a simple parent-child line — and you’d rather not thread props through every layer between them — reach for riposte-atoms: a Jotai-inspired model of small, independent, identity-based units of state.

Add the dependency (it pulls in the core transitively):

libraryDependencies += "io.github.edadma" %%% "riposte-atoms" % "0.0.1"

import io.github.edadma.riposte.atoms.*

Atoms

An atom is a handle to a unit of state — a key, not a container. The value lives in a Store; the atom just identifies it. A primitive atom is seeded with a value:

val countAtom = atom(0)   // WritableAtom[Int]

A derived atom computes its value from the atoms it reads, and recomputes automatically whenever any of those change. The get it receives both reads a dependency and records it:

val doubledAtom = atom(get => get(countAtom) * 2)        // read-only
val sumAtom     = atom(get => get(aAtom) + get(bAtom))   // tracks both

Because dependencies are recorded per evaluation, an atom that reads different atoms on different runs is tracked correctly each time.

Using atoms in components

Three hooks connect atoms to components, mirroring useState:

  • useAtom(a)(value, set) — read and write.
  • useAtomValue(a)value — read only.
  • useSetAtom(a)set — write only (doesn’t subscribe, so the component doesn’t re-render when the atom changes).
val Counter = view {
  val (count, setCount) = useAtom(countAtom)
  val doubled           = useAtomValue(doubledAtom)

  div(
    p(s"count: $count, doubled: $doubled"),
    button(onClick := (_ => setCount(count + 1)), "Increment"),
  )
}

Reading an atom subscribes the component to just that atom: it re-renders when that atom’s value changes and not otherwise. So Counter above re-renders when countAtom changes; doubledAtom recomputes and its readers re-render too; a component that reads neither is untouched. This fine-grained subscription is automatic — no selectors required for the common case.

Stores and scoping

Every atom hook resolves its store from the nearest StoreProvider ancestor, falling back to the process-wide Store.default. For most apps the default is all you need, and the examples above work with no setup.

To give a subtree its own isolated state — for tests, for resetting a region, or for rendering the same UI against different data — create a store with new Store and wrap the subtree in a StoreProvider. The same atoms then hold independent values under each provider:

val App = view {
  StoreProvider(new Store) {
    Counter()
  }
}

More atom kinds

Beyond primitive and read-only derived atoms, the module provides:

Writable-derived atoms — a derived value that also knows how to be written, by fanning the write out to the atoms that back it:

val celsiusAtom = atom(0.0)
val fahrenheitAtom = atom(
  read  = get => get(celsiusAtom) * 9 / 5 + 32,
  write = (get, set, f: Double) => set(celsiusAtom, (f - 32) * 5 / 9),
)

Action atoms — a write-only “command” that carries no readable value but mutates several atoms at once when dispatched:

val resetAll = action((get, set, _: Unit) => {
  set(countAtom, 0)
  set(celsiusAtom, 0.0)
})
// useSetAtom(resetAll) gives you a dispatcher

selectAtom(a, f) — derive a narrowed slice of a larger atom that only notifies when that slice changes, so readers don’t re-render on unrelated parts of the source.

atomFamily(make) — a function from a parameter to an atom, memoized so the same parameter always yields the same atom. Use it for per-id state (one atom per row, per user, …).

atomWithStorage(key, default) — a primitive atom that persists to localStorage under key and re-hydrates on load. A typed overload takes encode/decode for non-string values.

atomLoadable(future) — wraps an async computation as an atom whose value is a Loadable with loading / data / error states, so a component can render each phase without manual effect plumbing.

Next: Routing.

Search

Esc
to navigate to open Esc to close