Docs
Upgrading to 0.8.0
Five behaviour changes, one hazard if you roll back, and a handful of additions. Most applications need no code changes at all. Read the first section regardless; it is the one that can bite silently.
Pre-1.0, so this is a MINOR bump by the repository's policy.
#Read this one first: rolling back loses binary state
State persisted by 0.8.0 containing a Uint8Array, a DataView or an ArrayBuffer carries a
tag that 0.7.x does not recognise. Its decoder returns undefined for an unknown tag, so
downgrading silently loses that slice. Not an error, not a warning: the slice comes back
empty.
This only matters if you persist binary data and then deploy an older build.
// Bump the schema version before shipping 0.8.0, so an older build discards the
// payload instead of half-reading it.
persist(store, { key: "app", adapter, version: 4 /* was 3 */ });#replace* no longer removes what a library registered
You will notice if: you call replaceReducers, replaceMiddleware, replaceEffects or
hotReplace, and something registers on the store after construction.
A reducer, middleware or effect added with registerReducer, registerMiddleware or
registerEffect now survives a replace* call, along with its state. Those registrations were
never part of the set you are replacing.
This fixes far more than it changes. The HMR line core's own documentation recommended used to
delete a library's slice and its state on the first file save, and it silently turned the
disposers registerMiddleware handed back into no-ops.
// Old behaviour, if you actually want it:
store.replaceReducers(next, { scope: "all" });
store.hotReplace({ reducer: next, scope: "all" }); // forwards to all threeOne new error. If your next names a slice a library already mounted, replaceReducers
now throws, naming the slice and its owner, before mutating anything. Rename the slice, or pass
{ scope: "all" } deliberately.
#Middleware vetoes only on an explicit false
You will notice if: a middleware of yours returns undefined, 0, "", null or NaN
and you were relying on that to block the event.
// Before: vetoed everything it matched, silently.
// Now: allows the event, which is almost certainly what was meant.
store.registerMiddleware((state, event) => {
log(event); // no return
});
// Unchanged: an explicit false still vetoes, and so does a throw.
store.registerMiddleware(() => false);The documented contract always said false. The test was !result, so any falsy value vetoed,
and middleware that did its work and fell off the end swallowed every event it matched. The
symptom was reducers quietly stopping for one channel, which reads as a routing problem.
MiddlewareFunction now returns boolean | void, so an omitted return is legal.
#Time travel no longer re-runs your onEvent handlers
You will notice if: you use devtools: { allowReplay: true } and you have an onEvent
handler you want to run during a scrub.
Replay used to call every handler exactly as a live event would, so dragging the timeline re-published to peers, re-wrote to sockets and re-fired analytics for events that were not happening again. There was no way to detect it from inside a handler.
// Opt back in, for a handler that derives view state and performs no I/O.
store.onEvent("ui", "save", handler, "committed", { duringReplay: true });
// In React:
useEvent("ui", "save", handler, "committed", { duringReplay: true });
// And for anything that must branch rather than skip:
if (store.isReplaying) { /* ... */ }subscribe and connect keep firing throughout, so the UI still follows the scrub.
#useAtomicProps refuses an undeclared read
You will notice if: a selector reads state it did not declare, and you get your hooks from
createYoltra or createHooks.
// Throws in development now, naming the path.
useAtomicProps([{ reducer: "user", property: "name" }], (s) => s.user.email);This guard already existed on the package-level hooks. It was missing from the ones
createYoltra hands out, which is the path the documentation recommends, so the recommended
path was the unguarded one. In production the read was undefined and the component simply
stopped updating, because it is subscribed only to what it declared. Declare the path, or stop
reading it.
#Additions you may want
Knowing why an emit did not commit. committed: false used to arrive from three unrelated
causes through one shared object.
const result = await store.emit("orders", "submit", order);
if (!result.committed) {
if (result.reason === "vetoed") showError(`Blocked by ${result.vetoedBy ?? "a guard"}`);
// "deduped" is a double-click. Say nothing.
// "cascade" means the event exceeded the depth ceiling.
}Decorating a store, with types. registerSlice, withSlice, withMiddleware and
withEffect return the store re-typed, so a slice added at runtime is visible to getState()
and its channels become emittable. See the decoration guide.
Watching registrations. store.onRegistrationChange(observer, { emitCurrent: true }) fires
when the store gains or loses a reducer, middleware or effect.
A hook for subscriber errors. createStore({ onSubscriberError }) joins onEffectError,
onReducerError, onRejected and onCascade.
Binary in state and in storage. All nine typed arrays plus DataView and ArrayBuffer
round-trip through persistence and time travel. They also no longer throw at store
construction, which they did in development because freezing a view is a TypeError.
Honest deduplication. dedupWindowMs compares by content for Map, Set, Date,
BigInt, binary and cyclic payloads. Those all stringified to {} before, so distinct
payloads collided and the second event was silently dropped. If you use content dedup, you
may see events now arriving that were previously swallowed. That is the fix, not a regression.
#Nothing to do for
createStore, emit, getState, subscribe, connect, the entity adapter, Suspense hooks,
store.call, and every existing registerX call site. The registration methods return a
callable object now instead of a bare function, so const off = store.registerEffect(spec); off(); compiles and runs exactly as before.