Docs
Migration Guide
Coming from Redux, Zustand, or Jotai? This guide maps the concepts you already know onto Yoltra and shows the before/after for each.
#The one shift to internalize
Yoltra is event-sourced. You don't set state directly โ you emit an
event (channel, type, payload), and a pure reducer computes the next
state. Reads are fine-grained path subscriptions: a component re-renders
only when the exact leaf it reads changes. Async work lives in effects.
emit("todos", "add", { title: "Buy milk" }); // 1. emit an event
// 2. a reducer computes the next state (synchronously)
const title = useAtomicProp({ reducer: "todos", property: "items.0.title" }); // 3. read one pathThat is the whole model. Everything below is a translation of your current library into those three moves.
#Concept map
| Concept | Redux / RTK | Zustand | Jotai | Yoltra |
|---|---|---|---|---|
| Define state | createSlice | create(set => โฆ) | atom(initial) | reducer slice in createYoltra |
| Change state | dispatch(action) | set(...) | set(atom, v) | emit(channel, type, payload) |
| State update logic | reducer (switch) | inline in set | write atom | reducer (pure (state, event) => next) |
| Read state | useSelector | useStore(sel) | useAtomVal(atom) | useAtomicProp (fine-grained) |
| Derived value | reselect | selector fn | derived atom | useAtomicProps(specs, selector) |
| Async / side effects | thunk / RTK Query / saga | inside actions | atomWith... | effect (effects: [...]) |
| Intercept / guard | middleware | (manual) | (manual) | middleware (sync, can reject) |
| Provider | required | not needed | required (Provider) | optional (hooks default to the store) |
#From Redux / Redux Toolkit
Mapping: action โ event, dispatch โ emit, slice reducer โ reducer,
useSelector โ useAtomicProp, thunk / RTK Query โ effect,
middleware โ middleware (sync) or effect (async).
#Store + slice
// Redux Toolkit
const counter = createSlice({
name: "counter",
initialState: { value: 0 },
reducers: {
increment: (s, a: PayloadAction<number>) => { s.value += a.payload; },
reset: (s) => { s.value = 0; },
},
});
export const store = configureStore({ reducer: { counter: counter.reducer } });// Yoltra
import { createYoltra } from "@yoltra/react";
export type AppEM = { counter: { increment: number; reset: null } };
export const { useAtomicProp, useEmit } = createYoltra({
name: "App",
reducer: {
counter: {
state: { value: 0 },
events: [["counter", "increment"], ["counter", "reset"]],
reducer: (s, e) => {
switch (e.type) {
case "increment": return { value: s.value + e.payload };
case "reset": return { value: 0 };
default: return s;
}
},
},
},
});#Dispatch โ emit, useSelector โ useAtomicProp
// Redux
const value = useSelector((s: RootState) => s.counter.value);
const dispatch = useDispatch();
dispatch(increment(1));// Yoltra โ re-renders only when counter.value changes; no memo, no reselect
const value = useAtomicProp({ reducer: "counter", property: "value" });
const emit = useEmit();
emit("counter", "increment", 1);#Thunks / RTK Query โ effects
Async belongs in effects, which run after the reducer and can emit follow-up events (your success/failure actions):
// Redux thunk
const fetchTodos = () => async (dispatch) => {
dispatch(loading());
const res = await api.getTodos();
dispatch(loaded(res));
};// Yoltra effect
effects: [
{
when: { keys: [["todos", "fetch"]] },
effect: async (event, getState, emit) => {
const res = await api.getTodos();
await emit("todos", "loaded", res); // reduce the result like any event
},
},
],#Middleware
Redux middleware wraps dispatch. Yoltra middleware is synchronous and
returns a boolean โ return false to reject an event (it becomes an
"uncommitted" event your UI can react to). Async middleware work moves to
effects.
middleware: [
{
when: { channel: "admin" },
middleware: (state, event) => state.auth.isAdmin, // false โ rejected
},
],#From Zustand
Mapping: create(set => โฆ) โ createYoltra, set(...) โ emit + reducer,
useStore(selector) โ useAtomicProp.
// Zustand
const useStore = create((set) => ({
value: 0,
increment: (n) => set((s) => ({ value: s.value + n })),
reset: () => set({ value: 0 }),
}));// Yoltra โ state and transitions are separated: emit an event, reduce it
export const { useAtomicProp, useEmit } = createYoltra({
name: "App",
reducer: {
counter: {
state: { value: 0 },
events: [["counter", "increment"], ["counter", "reset"]],
reducer: (s, e) =>
e.type === "increment" ? { value: s.value + e.payload }
: e.type === "reset" ? { value: 0 }
: s,
},
},
});// Zustand: const value = useStore((s) => s.value); useStore.getState().increment(1);
// Yoltra:
const value = useAtomicProp({ reducer: "counter", property: "value" });
const emit = useEmit();
emit("counter", "increment", 1);Why the extra step? The action/reducer split is what buys you the event log,
time-travel, and DevTools โ Zustand's inline set can't be replayed or
inspected. In exchange you get fine-grained reads for free: useAtomicProp
re-renders on one leaf, no selector-equality tuning.
#From Jotai
Mapping: an atom โ a path in a slice; useAtomValue โ useAtomicProp;
derived atoms โ useAtomicProps(specs, selector); useSetAtom โ useEmit.
// Jotai
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2);// Jotai reads/writes
const count = useAtomValue(countAtom);
const doubled = useAtomValue(doubledAtom);
const setCount = useSetAtom(countAtom);
setCount((c) => c + 1);// Yoltra โ one slice, paths are your "atoms", derivations are selectors
const count = useAtomicProp({ reducer: "counter", property: "value" });
const doubled = useAtomicProp({ reducer: "counter", property: "value" }, (v) => v * 2);
const emit = useEmit();
emit("counter", "increment", 1);For a value derived from several paths, use useAtomicProps โ it re-runs
only when one of the listed paths changes:
const filtered = useAtomicProps(
[
{ reducer: "todos", property: "items.**" },
{ reducer: "filter", property: "q" },
],
(s) => s.todos.items.filter((t) => t.title.includes(s.filter.q)),
shallowEqual,
);Jotai gives you fine-grained reactivity bottom-up (many atoms); Yoltra gives you the same top-down (paths into slices) plus an event log and time-travel DevTools that the atom model doesn't have.
#Gotchas & FAQ
- "Where's
setState?" There isn't one by design. Emit an event; a reducer produces the next state. That indirection is what makes the whole history inspectable and replayable. - Reducers must be pure. No async, no I/O, no mutation of the previous state โ return a new value. Put async in effects.
getState()is correct right afteremit(). The reduce phase is synchronous.await emit(...)only when you also want that event's effects to have finished.- Do I need a Provider? No โ
createYoltra's hooks default to the store it created. Use<StoreProvider>only to scope a different instance to a subtree (e.g. a fresh store per test). - Channels? The extra
channeldimension namespaces events ("auth"/"ui"/"todos") so large apps don't collide on a flat action-type space. Pick channels by domain.
#Next steps
- Quick Start Guide โ install to working app in three steps
- Testing Guide โ unit-test stores, effects, and components
- @yoltra/core API ยท @yoltra/react API
- Library Comparison โ the honest architectural trade-offs