Docs
Decorating a store
A store is created by an application. A capability is often written by somebody else: a file transfer, a telemetry pipeline, a media session. That library needs to add a slice, guard some events and react to others, on a store it did not create and cannot change the definition of.
Yoltra already had the seams for that. What it did not have was types that survived using them, or a guarantee that a hot reload would not quietly undo the whole thing.
#The shape of the problem
Before 0.8.0, decorating a store looked like this:
// Don't write this any more.
store.registerReducer("transfers", transfersSpec as any);
store.registerMiddleware(guard as any);Two casts, and a third at every place the application later touched the slice, because
registerReducer took a plain string and returned nothing but a disposer. Nothing
downstream knew transfers existed, what shape it had, or which channels it answered to.
And then the developer saved a file:
if (import.meta.hot) {
import.meta.hot.accept("./reducers", (mod) => {
store.replaceReducers(mod.reducers, { preserveState: true });
});
}That line, which core's own documentation recommended, deleted the library's slice and its state. No error, no warning. The capability worked until the first hot reload.
#Declaring what a decoration contributes
Start with the spec builders. They exist for one reason, and it is worth understanding because it explains the whole shape of the API.
A spec's when carries channel and type strings:
when: { keys: [["transfer", "granted"]] }Strings, and no payload types. There is nothing there to infer an event map from. And
TypeScript has no partial type-argument inference, so a hypothetical
registerSlice<Name, State, EventMap> would force you to hand-write the name and the state
type any time you wanted to name the event map.
defineSlice puts the event map in a value position, where inference does work:
import { defineSlice } from "@yoltra/core";
type TransferEM = {
transfer: { granted: { id: string }; revoked: { id: string } };
};
export const transfers = defineSlice<TransferEM>()({
state: { granted: [] as string[] },
when: { keys: [["transfer", "granted"]] },
reducer: (s, e) => (e.type === "granted" ? { granted: [...s.granted, e.payload.id] } : s),
});Named once. Every registration site infers from it, with no type argument and no cast.
defineMiddleware and defineEffect do the same. One consequence is worth knowing before
it surprises you: a bare middleware function can never widen the event map.
// Registers fine. Contributes no channels, and cannot.
store.withMiddleware((state, event) => true);
// Contributes its channels.
store.withMiddleware(defineMiddleware<TransferEM>()({
when: { channel: "transfer" },
middleware: () => true,
}));MiddlewareFunction's event parameter is EventUnion<EM>, a mapped type nothing can be
inferred back out of. Only the spec form carries the map.
#Growing the store's type
const app = store.withSlice("transfers", transfers, { owner: "@scope/transfers" });
app.getState().transfers.granted; // string[]
app.emit("transfer", "granted", { id: "a1" }); // the new channel is emittablewithSlice, withMiddleware and withEffect all return the store with its types widened,
so calls chain:
const app = store
.withSlice("transfers", transfers)
.withMiddleware(quota)
.withEffect(uploader);It is the same object. Decoration is a type-level operation: nothing re-subscribes, no
state moves, the dedup cache is untouched, and an in-flight store.call() carries on. Only
the type changes.
store.withSlice("transfers", transfers) === store; // true#Publishing a decorator
A library exports a function that takes a store and returns one:
import type { EventMapBase, StoreInstance } from "@yoltra/core";
export function withTransfers<
R extends string,
S extends Record<R, any>,
EM extends EventMapBase,
>(store: StoreInstance<R, S, EM>, config: TransfersConfig) {
return store.withSlice("transfers", transfers, { owner: "@scope/transfers" });
}Generic over the incoming store, which is the part that matters. R, S and EM are
inference sites, so they take whatever the caller actually has, and decorators compose by
nesting in any order:
const decorated = withTransfers(withTelemetry(store, tConfig), config);
// or
const decorated = withTelemetry(withTransfers(store, config), tConfig);Both reach the same type. A decorator that adds only events, wrapped around one that also adds a slice, infers the already-widened values and carries them through.
withDevtools(store, config) is the degenerate case of this contract: it adds nothing and
returns the store unchanged.
#There is no pipe
It was considered and declined. Every decorator takes (store, config), so each step in a
pipe needs a lambda to become unary:
pipe(store, s => withA(s, cfgA), s => withB(s, cfgB)) // longer
withB(withA(store, cfgA), cfgB) // shorterA pipe only pays for curried decorators, which would be a different convention from the one
withDevtools already set, and every extra generic layer is another place inference can
degrade. If four-deep nesting ever becomes common, a pipe is purely additive and can arrive
then.
#Requiring another decoration
Constrain the input. No registry, no ordering table:
export function withAudit<
R extends string,
S extends Record<R, any>,
EM extends EventMapBase & TransferEM, // ← the dependency
>(store: StoreInstance<R, S, EM>) {
return store.withEffect(auditor);
}Applied to a store that has not been decorated yet, this fails at the call site and names the
channels that are missing. It still composes, because TypeScript infers EM from the
argument and then checks the constraint.
For a dependency that leaves no type trace, a decoration that binds with onEvent and adds
no channels, check at runtime instead:
store.onRegistrationChange(
(changes) => {
/* react, or throw naming what is missing */
},
{ emitCurrent: true },
);#Surviving a hot reload
replace* replaces what the application authored. Anything registered after construction
survives, along with its state:
store.registerSlice("transfers", transfers); // a library's slice
store.replaceReducers(appReducers); // the app's hot reload
store.getState().transfers; // still hereYou do not declare this and neither does the library. Provenance is recorded internally, because correctness must not depend on anyone remembering to pass a string.
Three details follow from it:
{ scope: "all" }restores the pre-0.8.0 behaviour exactly, for a test harness resetting a store between cases.hotReplaceforwards it to all three.- A collision throws. If the application authors a slice a library already mounted, you get an error naming the slice and its owner, thrown before anything is mutated. A silent takeover would leave the library holding a disposer for something no longer its own.
- A debug line says what was preserved, in development, so "why is that effect still firing after a reload" has an answer.
#React
// state/yoltra.ts - module scope, once.
export const app = createYoltra({ name: "App", reducer: { counter } })
.withSlice("transfers", transfers);
export const { useAtomicProp, useEmit, useEvent } = app;useAtomicProp({ reducer: "transfers", property: "granted" }) is typed, on a slice the
application never declared.
Three things to know:
- Module scope, once, before the first render. Each
with*builds a new hook set, becausecreateHooksallocates fresh function objects. Calling one inside a component would hand React a differentuseAtomicPropon every render. - Providers interoperate. The context object is re-typed, never recreated, so a
<StoreProvider>from any view in the chain serves the hooks of every other. The Suspense cache is shared for the same reason: it keys on store identity. - Free functions exist for a library handed a
Yoltrait did not create:withSlice(yoltra, name, spec).
#Watching what is installed
onRegistrationChange tells you when a store gains or loses a reducer, middleware or effect.
Devtools uses it to keep its panel current; a library can use it to react to another library.
const off = store.onRegistrationChange(
(changes) => {
for (const c of changes) {
if (c.origin === "internal") continue; // the store's own machinery
console.log(c.op, c.kind, c.name, c.owner);
}
},
{ emitCurrent: true },
);- Changes arrive in batches, one per public call.
replaceReducersupdates a slice by unmounting and remounting it, so a per-change view would show something merely being updated disappearing. emitCurrentsynthesizes a mounted change for everything already installed, delivered before the call returns. Spec-time registrations happen insidecreateStore, so a decorator applied afterwards never saw them arrive.statehas four values for a reducer, and the fourth is the one to watch:"retained"means the slice's state survived an unmount,"deleted"means it did not. Treating every unmount as destruction will tear down a subscription you are about to need.- Registering from inside an observer is fine. It is queued, not delivered re-entrantly, so nobody ever sees a half-built topology.
- Replay produces no changes. It alters state, never topology.
#Disposal, and the one thing types cannot express
withSlice returns no disposer. That is deliberate.
After a disposer runs, the widened type still promises a slice that is gone, and no type system can say "valid until that call". So the chaining API does not hand one out, and the footgun stays off the path most people take.
When you own the slice and need teardown, use registerSlice:
const reg = store.registerSlice("transfers", transfers, { owner: "@scope/transfers" });
reg.store; // the widened store
reg.dispose(); // library-private: do not export thisKeep that disposer inside the library. Handing it to application code hands out the ability to invalidate types the application is still relying on.
Reading a disposed slice throws a named error in development rather than returning
undefined from a type that promised a value:
[yoltra] Slice "transfers" was unmounted by its owner (@scope/transfers). Hooks and subscriptions widened for it are no longer valid.
#Ordering
Decorate at module scope, at import time, before the first render. Between createStore and
the decoration the slice genuinely does not exist, and a component reading it sees undefined
until it does. That window is safe, not broken, and it closes as soon as the slice mounts.