State

Three layers, who owns each, and why a question only sees part of it.

LIVE DEMO →order a menu by what comes next
layerwho sets itread in an ask as
ambientthe library, from tracked activityrecent_actions and time_spent
scopeyou, at the providerapp.* — any keys you like
localyou, at the call sitedata.*

They merge local over scope over ambient, and each layer is namespaced so they cannot collide. The library only collects the two things it can collect on its own — what the person did, and how long they have been here. Everything else is yours.

Adding your own fields

Anything you put in scope lands under app, and you can add whatever keys you like. Nothing about the library constrains their names.

<JevProvider
  resolve={askJev}
  state={{
    app: { role: 'analyst', tier: 'enterprise', familiarity: 'returning' },
    recent_actions: [],
    time_spent: 'short',
  }}
>

Then name it from a question. State that nothing references is still sent, but the model has no idea what it is for until an ask points at it:

<Gate ask="Given `app.tier` and `recent_actions`, is this person likely to need the advanced panel?" />

Every channel travels with every question by default. Narrow it with pick={[...]} when a state grows big enough that irrelevant detail starts costing accuracy.

Updating it

The state lives in the provider, so there is no store to wire up. useJevState reads it and gives you the three ways to change it.

const { state, track, setAppField, setState } = useJevState();

track('exported CSV');                 // appends to recent_actions, capped
setAppField('tier', 'enterprise');     // one of your own fields
setState({ time_spent: 'long' });      // anything, merged in

Every judgment that receives the changed state re-resolves. That is affordable because what goes in is banded words, not raw telemetry.

Observing every change

onStateChange fires after every change with the new state — not on the initial render. It is the general hook: persist it, sync it to a store you already have, log it. The library never decides what it means.

<JevProvider
  resolve={askJev}
  state={{ app: { user } }}
  onStateChange={(next) => {
    sessionStorage.setItem(key, JSON.stringify({
      recent_actions: next.recent_actions,
      time_spent: next.time_spent,
    }));
  }}
>

One handler, not a list — compose inside the arrow the way you would with any other React change handler. There is no built-in persistence yet: restoring on mount is yours to do, and doing it in an effect costs one extra judgment on first load because the first render sees the empty state.

Reacting to your own store

The state prop is live, not just an initial value. Pass it from Redux, a session, or a server prop and the provider syncs each key whose value changes — compared structurally, so an inline object literal does not re-sync on every render, and a key you never change never disturbs what the library collected.

const user = useSelector(selectUser);

<JevProvider resolve={askJev} state={{ app: { user } }}>

Keeping time_spent current

useTimeOnScreen() maintains it while someone stays on a screen. It sets timers for the band boundaries rather than polling, so it wakes at most four times and then stops — anything finer would re-resolve every judgment on a ticking interval for no gain in what the model can tell.

function ReportScreen() {
  useTimeOnScreen();   // time_spent: very short -> short -> medium -> long -> very long
  return <Branch ask="Given `time_spent`, does this person want the detailed view?" … />;
}

Narrowing with pick

Accuracy degrades on a large state full of irrelevant detail, so a question can take less: pick={["recent_actions"]} sends only that channel plus its own data. The Inspector marks which fields went with the last request.

Telemetry becomes words

Jev underperforms on numeric representations and does not count reliably, so raw telemetry is the wrong input. The library projects each signal onto a named band before it is ever sent.

collectedsent
41200 ms on the screentime_spent: "long"
a click streamrecent_actions: ["filtered to EMEA", "exported CSV"] — the last 12 labels
7 visits, in your own fieldapp.familiarity: "returns often" — via bucketFamiliarity

Banding has a second benefit: it keeps re-resolution rare. Milliseconds churn on every tick, while "short" becomes "medium" a handful of times.

When a judgment re-resolves

  • Its question changed.
  • Its data changed.
  • Any state it receives changed — which is affordable because that state is banded words, not raw telemetry.
  • You called revalidate() from the provider.