Skip to content
ChatfuelSDK
Concepts

State

A pure reducer, a useReducer binding and a throwing context accessor — the whole state convention, with no state library in the app.

There is no state library in the app, and no router library either. The vendored dependency set stays frozen, so everything a module needs to hold state is already in React — and the convention that follows is three files with three jobs.

The triad

FileWhat it is
lib/<name>Store.tsa pure reducer — no React, no clock, no fetching, directly testable
hooks/use<Name>Store.tsthe useReducer binding, and the effects that feed it
<Name>Context.tsa throwing accessor, so a component outside the provider fails loudly

The third one is four lines and earns them. useContacts() throws useContacts must be used inside <ContactsApp> rather than handing back null for somebody to optional-chain past — a component in the wrong place is a mistake you want at mount time, in a message that names the provider.

A module can have several of these. bookings has seven stores, because a calendar range, a service catalog and a staff form are three unrelated pieces of state that happen to share a screen.

Why the reducer is the half worth testing

Splitting the reducer out is not tidiness. It is what makes the hard parts assertable, and every store in the app is built to be tested this way:

  • The reducer never reads the clock. now arrives in the action. So a test about a row that flashes for 1400 ms, or a live arrival that stays marked for 2000 ms, is exact rather than timing-dependent.
  • The stale-response guard is in state, not in a ref. Every request-shaped action carries the epoch it was issued under and is dropped if that epoch has moved on. In a ref that guard would be invisible to a test; in state, a test can drive it.
  • There is one record cache and one order. Rows live in a byId map and the views hold ordered ids, so a live echo, a page append and an optimistic edit all write the same cache and two surfaces cannot show two versions of one record.

The tests import the reducer, its initial state and its selectors — and nothing from React. That is the point: no render, no fake timers, no waiting.

There are exceptions, and they are written down where they live. In the deals board a live batch is guarded by loading rather than by the epoch, because gating it on the epoch would make the subscription effect depend on the epoch and tear the WebSocket down on every refetch.

The URL keys live in one pure file per module

Deep-link state has its own file, lib/<name>Params.ts, and it is pure the same way the store is: parse in, serialize out, no React. Two rules hold the whole thing together.

An unknown value falls back silently. A hand-edited or stale address renders the default. It must never throw and never white-screen, because the link somebody is opening was minted three releases ago.

A default is omitted from what gets written. Otherwise every mount would rewrite the URL with the full schema, and a link you shared would carry ten parameters that say nothing.

What stays out of the URL is as deliberate as what goes in. Attribute filter groups are unbounded in size — a link carrying twenty predicates is not a link — so they live in saved views instead.

What it costs

You get no devtools, no time-travel and no selector library. Nothing memoises for you, and nothing stops two modules from solving the same problem twice, because they cannot import each other's solution.

The trade is that a store is a file you can read end to end in one sitting, and its test needs no runtime at all.

The other half of a module's state is its address, and that half belongs to the shell — see routing.

On this page