Stone.jsDocs
Paradigm

Extensions

Store: universal state

A store holds state your pages and services read. What makes this one worth having in the framework is not the API, which is small on purpose. It is that state has to cross the server-to-browser boundary, and crossing it correctly is not something a store can do from the outside.

#Install and enable

terminalbash
npm i @stone-js/store

Then declare a store, in either paradigm. The name is how it is resolved: store.tasks in the container.

app/stores/TasksStore.tsdeclarativeimperative
import { Store } from '@stone-js/store'

@Store({ name: 'tasks', state: { items: [], filter: 'all' } })
export class TasksStore {}
import { defineStore } from '@stone-js/store'

export const TasksStore = defineStore({
  name: 'tasks',
  state: { items: [], filter: 'all' }
})

#A feature's store, as a class

A data declaration is state with no behaviour. A feature usually wants more: the competition module has its client, its service and its store, and the store's actions call the client. Write it as a class extending StateStore and the container builds it, so its constructor is auto-wired like any other class:

app/competition/CompetitionStore.tsdeclarativeimperative
import { FeatureStore, StateStore } from '@stone-js/store'

interface CompetitionState {
  list: Competition[]
  selected?: Competition
}

@FeatureStore('competition')
export class CompetitionStore extends StateStore<CompetitionState> {
  private readonly client: CompetitionClient

  constructor ({ competitionClient }: { competitionClient: CompetitionClient }) {
    super({ list: [] })
    this.client = competitionClient
  }

  async load (): Promise<void> {
    this.setState({ list: await this.client.list() })
  }

  select (id: string): void {
    this.setState((state) => ({ selected: state.list.find((c) => c.id === id) }))
  }
}
import { defineStore, StateStore } from '@stone-js/store'

// The same class, registered imperatively: neither paradigm can do what the other cannot.
export const competitionStore = defineStore(CompetitionStore, { name: 'competition' })

// Or a factory, for full control with the container in hand.
export const liveStore = defineStore(
  (container) => StateStore.create({ scores: container.make('feed').initial() }),
  { name: 'live', isFactory: true }
)

Everything a data store gets, a class store gets too: resolved under store.competition, hydrated from the snapshot before the first render, and per-request on the server by default. Declaring it is the whole setup, because the decorator carries the module's blueprint with it.

#Reading and writing

The store is a container binding like any other, so a page reaches it through useContainer() and a service receives it in its constructor. There is no provider to wrap your tree in, and no import that only works on one side of the boundary.

app/components/TaskList.tsxtsx
import { useContainer } from '@stone-js/use-react'

export function TaskList () {
  const store = useContainer().make('store.tasks')
  const items = store.select((state) => state.items)

  return <ul>{items.map((item) => <li key={item.id}>{item.title}</li>)}</ul>
}
app/TaskService.tsts
// Anywhere the container reaches: a handler, a service, a page.
constructor ({ 'store.tasks': tasks }) {
  this.tasks = tasks
}

addTask (task) {
  this.tasks.setState((state) => ({ items: [...state.items, task] }))
}

#Why this belongs to the framework

The principle

State that outlives one runtime has to cross the boundary between them, and whoever owns the boundary owns the crossing. A store bolted on from outside cannot see the channel, so the crossing becomes glue written by hand in every application.

In Stone.js

Server rendering already serialises a page's data into the HTML: keyed per request, escaped against injection, read before anything renders. A solved channel. This store does not invent one, it uses that one, which is exactly what a store outside the framework cannot do, because it cannot write into a snapshot it does not know exists.

#Hydration happens at registration, before the first render

The store is filled from the snapshot when it is registered in the container, not in an effect after the tree has mounted. That ordering is the difference between a page that renders its real state immediately and one that renders empty, then flashes. The flash is not a styling problem; it is what hydrating too late looks like.

#One declaration, isolated per request on the server

A store that is a module-level singleton leaks state between requests during server rendering: one visitor's data reaching another's page. It is the single most common failure in universal state, and it is invisible until it is not.

The kernel already creates an ephemeral container per event, so the store resolves per request on the server and as a singleton in the browser, from the same declaration. You do not choose, and you cannot get it wrong by forgetting which side you are on.

app/stores/TasksStore.tsts
@Store({
  name: 'tasks',
  state: { items: [] },
  perRequest: true      // the default: fresh per request on the server, shared in the browser
})
export class TasksStore {}

#Serialisation is stated, not guessed

A snapshot carries JSON. A store holding a Map, a Date or a class instance therefore either says how it serialises, or is told that it cannot, because the alternative is [object Object] appearing after hydration, in a place far from the declaration that caused it. dehydrate and hydrate are where that conversion lives when the state is not plain data.

#Selector equality, documented rather than folklore

A selector that builds a fresh object on every call compares unequal to itself, and a component subscribed to it re-renders forever. watch compares before it notifies, so the core does not amplify the mistake, and the rule stays written down here rather than passed around: select values, or memoise what you build.

State crosses the boundary once, and arrives before the first paint.

#The API

MemberTypeDescription
getState()() => StateThe current state, as a copy.
setState(patch)(patch | fn) => voidMerge a patch, or map the current state.
replaceState(state)(state) => voidReplace it wholesale.
select(selector)(fn) => ValueRead a value out of the state.
watch(selector, fn)(fn, fn) => stopReact to one value changing; compares before notifying.
subscribe(fn)(fn) => stopReact to any change.
reset()() => voidBack to the declared initial state.
dehydrate() / hydrate(v)() => JSON / (JSON) => voidHow the state crosses the boundary when it is not plain data.

Stone.js

Your app exists in every runtime. Until you run it.

An open-source project by Stone Foundation
Created by Mr. Stone (Evens Pierre)