V2 Migration Guide
Zedux v2 introduces lots of new features and several breaking changes. This guide will give a basic introduction to each new feature and highlight some important breaking changes and migration tips. The full list of changes is at the end.
If you know the gist of Zedux v2, you can skip to the end for the full migration details.
React 19
Zedux v2 works best with React 19. Scoped atoms require React 19's new use util for hooking into React context. And we removed some old code for working around React 18's useId StrictMode bug.
This means you can use Zedux v2 in React 18 if you
- disable React StrictMode
- don't use scoped atoms (or call
ecosystem.withScopeyourself, but that can be tedious)
Upgrade React to v19 to get those features back.
Feature: Signals
Stores no longer exist in the @zedux/atoms or @zedux/react package by default. They're replaced with signals.
Atoms are signals! Try swapping in the commented-out usernameAtom for the usernameSignal. Behavior is the same.
Signals can be created anywhere via ecosystem.signal:
const signal = ecosystem.signal('some state')
Events
All graph nodes emit events. Signals have several built-in events:
change- fires on state change.cycle- fires when the signal's lifecycle status changes (e.g. fromInitializingtoActiveorActivetoDestroyed).mutate- fires when transactions are generated from asignal.mutatecall.
Atoms also have a few atom-specific built-in events:
invalidate- fires whenatomInstance.invalidate()is called.promiseChange- fires when the atom's promise reference changes.
Signals can also be given custom, typed events. These types propagate to all mapped signals and atoms the signal is composed in.
Mutations
Signals have proxy-powered, transaction-translated mutations:
Why Signals?
Apart from these new features, the main reason we decided to switch to signals is that stores are so different from atoms. Storing state, reading state, and reacting to state updates are all different.
Signals, on the other hand, use the same exact graph-based paradigm for reactivity. As a result, Zedux's API surface area is essentially halved.
The switch naturally removes several edge cases with synchronizing stores and atoms. It also lets us remove so much code that all these new features are free, bundle-size-wise, and then some. The minified build of Zedux v2 is a few kb smaller than v1.
For more info, see the original signals spec.
Feature: Ecosystem Events
The old plugin system is gone, replaced with "ecosystem events". Simply call ecosystem.on to register a listener
Plugin Migration
Full example of migrating a logging plugin to the new format:
// before:
const loggingPlugin = new ZeduxPlugin({
initialMods: ['stateChanged'],
registerEcosystem: ecosystem => {
const subscription = ecosystem.modBus.subscribe({
effects: ({ action }) => {
if (action.type === ZeduxPlugin.actions.stateChanged.type) {
console.log(
'node state updated',
action.oldState,
'->',
action.newState
)
}
},
})
return () => subscription.unsubscribe()
},
})
myEcosystem.registerPlugin(loggingPlugin) // register
myEcosystem.unregisterPlugin(loggingPlugin) // unregister
// after:
const cleanup = myEcosystem.on('change', event => {
console.log('node state updated', event.oldState, '->', event.newState)
})
Feature: Scoped Atoms
This feature requires React 19
The new inject util creates "scoped" atoms. Scoped atoms can only be initialized/retrieved in a scoped context:
- in any React component
- in an
ecosystem.withScopecallback
inject retrieves provided atoms and React contexts.
Other New Features
ecosystem.findAllnow accepts all the same filtering parameters asecosystem.dehydrateand then some:- Pass an
@-prefixed string like@atomor@selectorto only return nodes of that type. - The types have been improved to make editors autocomplete those
@-prefixed strings, leading to some slick DX. - Pass an array of filters (
.findAll([...myFilters])) as a shorthand for.findAll({ include: [...myFilters] })
- Pass an
untrack- A new top-level export for bumping out of a reactive context.AtomProvidernow accepts function overloads for itsinstanceandinstancesprops. The function will receive the ecosystem as its first parameter and should return an atom instance (or an array of instances for theinstancesprop).- When called with no deps,
injectMemonow automatically tracks any signal usages in the callback and reactively updates when they change, causing the injecting atom to reevaluate. ecosystem.withScope- Runs a callback in a scoped context with the passed scope.injectCallbacknow wraps the callback inecosystem.withScope(in addition toecosystem.batch) if the injecting atom is scoped.- Atom APIs wrap exports in
ecosystem.batchand, if the atom that's evaluating whenapi()is called is scoped,ecosystem.withScope. Disable this by passing{ wrap: false }as the second parameter toapi.setExportsandapi.addExports.
Breaking Changes
Atom Getters
"Atom getters" are now just the function properties on the ecosystem. In v1, the ecosystem's get and getInstance function properties were used to specifically avoid registering graph dependencies. Now they do register graph dependencies when called in reactive contexts.
To avoid registering graph dependencies, there are several new features:
import { Ecosystem, untrack } from '@zedux/react'
function maybeCalledReactively() {
return rootEcoSystem.get(myAtom)
}
// the ecosystem itself is now the first argument to selectors/ions:
function myExampleSelector((ecosystem: Ecosystem) => {
ecosystem.get(myAtom) // registers a dynamic dependency on myAtom
ecosystem.getNode(myAtom) // registers a static dependency on myAtom
ecosystem.getOnce(myAtom) // doesn't register anything
ecosystem.getNodeOnce(myAtom) // doesn't register anything
untrack(() => ecosystem.get(myAtom)) // doesn't register
let myAtomVal = maybeCalledReactively() // registers
myAtomVal = untrack(() => maybeCalledReactively()) // doesn't register
})
While using the old AtomGetters type will still work for typing the first argument of atom selectors, it's deprecated. Prefer Ecosystem instead - we'll probably require it in v3.
Stores
Stores now live in the @zedux/stores package. They are not deprecated, you can still use them. But it's recommended to use signals when possible 'cause they're better. And because of that, we may officially deprecate stores someday. That's a maybe, but keep it in mind.
Every export in the @zedux/stores package has been renamed to be store-specific. Some examples:
atom->storeAtomapi->storeApiion->storeIonAtomInstance->StoreAtomInstance
See below for the full list.
injectEffect Order
In v1, injectEffect callbacks would always run in the next event loop cycle (via setTimeout) unless another event, e.g. a state update, triggered a scheduler flush. This led to confusing event ordering in some cases, especially in tests.
Zedux now runs effects as soon as it's "safe" to do so.
- When atoms are initialized during a React component render, effects now always run in a microtask.
- When atoms are initialized or evaluate outside React, all queued effects run immediately before the top-level
ecosystem.get*call returns.
This is a big improvement when working with atoms outside React, especially in tests, as before you couldn't be sure if manual subscriptions were registered yet when using an atom. Now you can.
This ordering change may break effects that were expecting other synchronous code to run between atom initialization and the deferred effect.
const exampleAtom = atom('example', () => {
const store = injectStore(0)
injectEffect(() => {
console.log(store.getState()) // "1" in v1, "0" in v2
}, []) // adding `store.getState()` as a dep would fix this in v2
return store
})
const exampleInstance = ecosystem.getInstance(exampleAtom)
exampleInstance.setState(1)
For example, we had some test code awaiting an already-resolved promise right after initializing an atom. That microtask was expected to run before the effect (which was scheduled with setTimeout in v1) but in v2 it doesn't. In this case, the effect needed to explicitly await the asynchronous operation.
Hydration
Automatic hydration works almost the same in v2, with one key difference: Hydrations aren't "consumed". This means they're kept in memory (in ecosystem.hydration) and will be reused if the atom is destroyed and recreated.
To prevent this, you can hook into the cycle ecosystem event and delete hydrations yourself.
const cleanup = ecosystem.on('cycle', event => {
// you can check specific ids or template keys (`event.source.template.key`):
if (event.source?.id && ecosystem.hydration[event.source.id]) {
// you are free to mutate this object:
delete ecosystem.hydration[event.source.id]
// you can call `cleanup` after all expected nodes have been hydrated
}
})
Usually you won't need to do this. It's just an escape hatch if you have particularly heavy hydration data.
For manual hydration, injectHydration is the new kid on the block.
Previously, store hydration was tied to individual injectStore calls. This made hydration difficult when there are multiple hydration sources (e.g. localStorage, URL state, and server snapshot) or each store only controlled part of the atom's state.
injectHydration gives you full control. It will be usable in combination with other hydrators so you can determine priority when manually hydrating, especially in "local-first" setups.
const multiSourcedAtom = atom('multiSourced', () => {
const fromServer = injectHydration<string>()
// two example third-party injectors:
const fromLocalStorage = injectLocalStorageHydration<string>()
const fromUrl = injectUrlHydration<string>()
const signal = injectSignal(
fromUrl ?? fromLocalStorage ?? fromServer ?? 'the default value'
)
return signal
})
As always, these injectors can be consolidated into a single custom injector:
const signal = injectLocalFirstSignal('the default value')
Manual hydration is recommended in this case of having multiple third-party hydrators, as otherwise you have to rely on listener registration order to determine priority. With only one third-party hydrator, automatic hydration is fine, though it will result in three total initial evaluations.
Id Generation
Zedux node ids are now more readable and consistent. Everything but atoms now uses consistent id @ prefixes. See the jsdoc for Ecosystem#makeId for the full list.
The default id generator also doesn't add any randomized hashes to ids in v2. Most apps shouldn't need those, so they were unnecessary overhead. Ids are still guaranteed to be unique inside the ecosystem. This default is ideal for testing too, since ids are now predictable.
If a uniqueness guarantee is needed outside the ecosystem, e.g. when working with multiple ecosystems (not common), you can now supply your own id generator. Just pass a makeId function to createEcosystem. It will receive the id prefix and should return a unique string.
For example:
const ecosystem = createEcosystem({
makeId: function (...args) {
return `${Ecosystem.prototype.makeId.apply(this, args)}-${uuid()}`
},
})
This new id generation will likely break any code that checked for exact id matches. If you have lots of code relying on this or snapshot tests capturing the old ids, you can get them back by supplying a custom id generator. This should do:
const ecosystem = createEcosystem({
makeId: function (nodeType, context, suffix) {
const prefix = nodeType === 'selector' ? '@@selector-' : ''
const content =
nodeType === 'component' && context === 'unknown'
? 'rc'
: nodeType === 'listener'
? 'no'
: nodeType === 'selector' && context === 'unknown'
? 'unnamed'
: (context as GraphNode)?.id ?? context ?? ''
const uniqueId =
suffix === ''
? ''
: suffix
? `-${suffix}`
: `-${++this.idCounter}${Math.random().toString(36).slice(2, 8)}`
return `${prefix}${content}${uniqueId}`
},
})
Other Changes
ecosystem.findAll now returns an array instead of an object keyed by node id. This is to make it easier to sort/filter/map/reduce the returned list yourself.
The default filtering logic is only designed for the default id generation. Now that you can easily customize ids with makeId, we need an API that lends itself to custom filtering of those ids better. Simply adding the ability to chain array operations directly off the .findAll() call is really all we need. It also makes manual dehydration easier:
// custom dehydration example
ecosystem
.findAll('@atom')
.filter(myCustomFilter)
.reduce((obj, node) => ({ ...obj, [node.id]: node.get() }), {})
The new signals-based ions now set ttl: 0 by default. This reflects their primary purpose - to derive state. Derivations are usually transient and should be cleaned up (by default!) when no longer in use.
Migrating to Signals
To upgrade to v2 initially, you can replace most @zedux/react imports with @zedux/stores and alias the imports:
// before:
import { api, atom, injectMemo, injectStore } from '@zedux/react'
// after:
import { injectMemo } from '@zedux/react'
import { storeApi as api, storeAtom as atom, injectStore } from '@zedux/stores'
For bigger apps, it's recommended to migrate incrementally to signals. For most store-based atoms it will be as simple as this:
// before:
import { storeApi as api, storeAtom as atom, injectStore } from '@zedux/stores'
// after:
import { api, atom, injectSignal } from '@zedux/react'
// replace `injectStore` with `injectSignal`
// replace `store.getState` with `signal.get`
// replace `store.setState` with `signal.set`
// replace `store.setStateDeep` with `signal.mutate`
Some apps may be able to easily move directly to signals, especially if no manual store subscriptions or composed stores are involved. Globally replacing injectStore with injectSignal, getState with get, and setState with set completely "just worked" in one of my example projects.
Some more complex migration scenarios:
- Composed stores
// before:
const storeA = injectStore('state a')
const storeB = injectStore('state b')
const composedStore = injectStore(
hydration => createStore({ a: storeA, b: storeB }, hydration),
{ hydrate: true }
)
composedStore.use({ a: storeA, b: storeB })
// after (simple, but will cause a reevaluation):
const signalA = injectSignal('state a')
const signalB = injectSignal('state b')
const hydration = injectHydration()
const composedSignal = injectMappedSignal({ a: signalA, b: signalB })
composedSignal.set(hydration)
// after (no reevaluation):
const hydration = injectHydration()
const signalA = injectSignal(hydration.a ?? 'state a')
const signalB = injectSignal(hydration.b ?? 'state b')
const composedSignal = injectMappedSignal({ a: signalA, b: signalB })
Note that the "after" example that causes a reevaluation is functionally similar to not doing any manual hydration logic. Zedux automatically hydrates atoms immediately after initial evaluation:
// this atom behaves the same with or without these commented lines:
const hydratingAtom = atom('hydrating', () => {
const signalA = injectSignal('state a')
const signalB = injectSignal('state b')
// const hydration = injectHydration()
const composedSignal = injectMappedSignal({ a: signalA, b: signalB })
// composedSignal.set(hydration)
return composedSignal
})
To prevent the double-evaluation, always hydrate at the source; instead of hydrating derived atoms/signals (like this mapped signal), hydrate the atoms/signals they derive values from.
- Store effects
// before:
const store = injectStore('my state')
injectEffect(() => {
const subscription = store.subscribe({
effects: ({ action }) => {
console.log('got metadata', action.meta) // untyped :(
},
})
return () => subscription.unsubscribe()
}, [])
store.setState('new state', { someMetaData: 'for the effect' })
// after:
const signal = injectSignal('my state', {
events: {
sendMetaData: As<{ someMetaData: string }>,
},
})
injectEffect(() => {
return signal.on('sendMetaData', ({ someMetaData }) => {
console.log('got metadata', someMetaData) // typed! :)
})
})
signal.set('new state', { sendMetaData: { someMetaData: 'for the listener' } })
Full List
Enough talk. Let's fight.
This section is a work in progress
Replace
ecosystem.get->ecosystem.getOnceecosystem.getInstance->ecosystem.getNodeOnceecosystem.select->ecosystem.getOnceatomGetters.get->ecosystem.getatomGetters.getInstance->ecosystem.getNodeatomGetters.select->ecosystem.select(but preferecosystem.get, see below deprecation note)ecosystem.registerPlugin->ecosystem.on()(see above example. See below for the list of mod-equivalent ecosystem events)ecosystem.wipe->ecosystem.resetecosystem.destroy->ecosystem.resetatomInstance.addDependent->atomInstance.on, passing{ active: true }as the last parameter (listeners are "passive" by default, meaning they don't impact node lifecycles).atomInstance._promiseStatus->atomInstance.promiseStatusatomInstance._promiseError->atomInstance.promiseError{ flags }->{ tags }(in atom config objects){ includeFlags, excludeFlags }->{ includeTags, excludeTags }(in the object passed toecosystem.dehydrate)atomTemplate.getInstanceId->atomTemplate.getNodeIdecosystem._idGenerator.generateId->ecosystem.makeId. Use the newmakeIdecosystem config option in tests instead of mocking Zedux APIs.AtomSelectorOrConfig->SelectorTemplate- The
initialStateoption ofinjectPromise->initialData. - The
dataOnlyoption ofinjectPromise-> use the returneddataSignalinstead.
For mods (previously added to ZeduxPlugins, now "ecosystem events" registered with ecosystem.on), replace:
ecosystemWiped->resetEnd. Use in combination withresetStartto capture/restore values.edgeCreated->edge. Use theevent.actionproperty to distinguish between edgeadd,update, andremoveedgeRemoved->edge(same note)evaluationFinished->runEnd. Use in combination withrunEndto time atom/selector evaluation time yourself.stateChanged->changestatusChanged->cycle
Note that the instanceReused mod is removed with no equivalent (see below). Also note the new ecosystem events, error, invalidate, and promiseChange.
Optionally replace deprecated APIs. May be needed for TS support in rare cases. These will be required in Zedux v3:
atomInstance.setState->atomInstance.setatomGetters.select(selector, arg1, arg2)->atomGetters.get(selector, [arg1, arg2])useAtomSelector(selector, arg1, arg2)->useAtomValue(selector, [arg1, arg2])injectAtomSelector(selector, arg1, arg2)->injectAtomValue(selector, [arg1, arg2])ecosystem.getInstance->ecosystem.getNodeinjectAtomGetters->injectEcosystem
Also optionally replace some deprecated and/or "legacy" types:
AtomGetters->EcosystemAtomExportsType->ExportsOfAtomInstanceType->NodeOfAtomParamsType->ParamsOfAtomPromiseType->PromiseOfAtomStateType->StateOf
Remove
manualHydrationatom config option.AtomInstanceBaseusages. This was an unnecessary superclass. TheAtomInstanceclass itself now handles everything this class did.atomDefaultsecosystem config options. Use custom atom factories to share atom behaviors. Or useions to createttl: 0atoms easily.ZeduxPluginusages. Use ecosystem events instead.instanceReusedmods. No replacement. We're working on build tool plugins to do what this mod was trying to do. Those will be released later.- reading
sourceTypefield on evaluation reasons. internalStoreusages. Zedux no longer stores ecosystems in module-level state. Pass them around yourself as needed.wipeusages (the top-level export). Zedux no longer has internal state to clear.- any other usages of
ecosystem._idGenerator.
Keeping Stores
If you're using stores extensively, especially with lots of advanced features, it may be easier to keep using stores for now and migrate incrementally to signals.
To do this, replace any store-atom-related imports from @zedux/atoms or @zedux/react with the relevant imports from @zedux/stores. It may be easier to use import aliases to map the new names to the old. Refer to this for the full list:
import {
// APIs you may want to alias when migrating from Zedux v1:
injectStorePromise as injectPromise,
storeApi as api,
storeAtom as atom,
StoreAtomApi as AtomApi,
StoreAtomInstance as AtomInstance,
StoreAtomTemplate as AtomTemplate,
storeIon as ion,
StoreIonTemplate as IonTemplate,
// APIs that don't need aliasing:
actionFactory,
createReducer,
createStore,
detailedTypeof,
doSubscribe,
getMetaData,
getStoreInternals,
injectStore,
is,
isPlainObject,
removeAllMeta,
removeMeta,
setStoreInternals,
Store,
zeduxTypes,
} from '@zedux/stores'
import type {
// Types you may want to alias when migrating from Zedux v1:
AnyStoreAtomApi as AnyAtomApi,
AnyStoreAtomApiGenerics as AnyAtomApiGenerics,
AnyStoreAtomGenerics as AnyAtomGenerics,
AnyStoreAtomInstance as AnyAtomInstance,
AnyStoreAtomTemplate as AnyAtomTemplate,
PartialStoreAtomInstance as PartialAtomInstance,
StoreAtomApiGenerics as AtomApiGenerics,
StoreAtomApiGenericsPartial as AtomApiGenericsPartial,
StoreAtomApiPromise as AtomApiPromise,
StoreAtomGenerics as AtomGenerics,
StoreAtomGenericsToStoreAtomApiGenerics as AtomGenericsToAtomApiGenerics,
StoreAtomInstanceRecursive as AtomInstanceRecursive,
StoreAtomStateFactory as AtomStateFactory,
StoreAtomTemplateRecursive as AtomTemplateRecursive,
StoreAtomValueOrFactory as AtomValueOrFactory,
StoreIonInstanceRecursive as IonInstanceRecursive,
StoreIonStateFactory as IonStateFactory,
StoreIonTemplateRecursive as IonTemplateRecursive,
StoreSettable as Settable,
// types that don't need aliasing:
AtomTemplateType,
AtomStateType,
AtomStoreType,
AtomInstanceType,
AtomParamsType,
AtomPromiseType,
AtomEventsType,
AtomExportsType,
Action,
ActionChain,
ActionCreator,
ActionFactory,
ActionFactoryActionType,
ActionFactoryPayloadType,
ActionFactoryTypeType,
ActionMeta,
ActionMetaType,
ActionPayloadType,
ActionType,
ActionTypeType,
Branch,
Composable,
Dispatchable,
Dispatcher,
EffectType,
EffectsSubscriber,
ErrorSubscriber,
HierarchyDescriptor,
KnownHierarchyDescriptor,
NextSubscriber,
Observable,
Reactable,
RecursivePartial,
Reducer,
ReducerBuilder,
Scheduler,
Selector,
SetState,
StateSetter,
StoreEffect,
StoreStateType,
SubReducer,
Subscriber,
SubscriberObject,
Subscription,
} from '@zedux/stores'
The easiest way to do this at scale is to alias the @zedux/react package to point to a local file that re-exports all Zedux's APIs, named as above.
Additionally, if you're currently sharing Zedux internals across windows via getInternals/setInternals, you'll also need to use the store package's getStoreInternals and setStoreInternals:
// an example parent-child setup
// parent window:
import { getInternals } from '@zedux/react'
import { getStoreInternals } from '@zedux/stores'
window.zeduxInternals = getInternals()
window.zeduxStoreInternals = getStoreInternals()
// child window:
import { setInternals } from '@zedux/react'
import { setStoreInternals } from '@zedux/stores'
setInternals(window.opener.zeduxInternals)
setStoreInternals(window.opener.zeduxStoreInternals)
Replacing Stores
When migrating (either incrementally or all at once) to signals, do the following:
Replace:
injectStore->injectSignal(see above for migrating composed stores and hydrations)- use
injectHydrationand initialize/set signals yourself.
- use
atomInstance.getState->atomInstance.getOnce(but prefer.getfor automatic reactivity)atomInstance.setState->atomInstance.setatomInstance.setStateDeep->atomInstance.mutate- (if you previously migrated to using the
@zedux/storespackage): Remove any import aliases (likestoreAtom as atom->atom) and import from@zedux/react(or@zedux/atomsif not using React) instead.
Remove:
ttl: 0in ions. This is the default for signals-based ions. That also means you may need to addttl: -1(infinity) to ions that you don't want to givettl: 0(tip: If you're unsure, leave the default).createStore. Use mapped signals instead for composing state primitives. Useecosystem.signalinstead for creating state containers on the fly.atomInstance.store. Signal-based atoms don't expose the underlying signal. Use the atom instance itself; it forwards all state updates and sent events to wrapped signalsatomInstance.store.getState->atomInstance.getOnce(but prefer.getfor automatic reactivity)atomInstance.store.setState->atomInstance.setatomInstance.store.setStateDeep->atomInstance.mutateatomInstance.store.subscribe->atomInstance.on(see signal events above)
atomInstance.dispatch. Can be replaced withatomInstance.sendfor custom events- Same goes for
atomInstance.store.dispatch. Replace withatomInstance.sendif possible atomInstance._isEvaluating. This is only present on store atom instances. For signals-based atoms, you don't need it. Buffering store updates was its primary purpose. Signals, however, can always be set during atom/selector evaluation; the graph will always resolve. But if plugin authors do need it, they can use the purposefully obfuscatedgetInternals().c.nto see what's currently evaluating.
Internal Changes
Some public-but-underscore-prefixed properties changed. Most apps shouldn't have been using these.
ecosystem._consumeHydration. Removed. Thecycleecosystem event can be used to deleteecosystem.hydrationentries yourself when hydrated nodes becomeActive._createdAt(the property on atom and selector instances). Removed. Plugins can track time created themselves if needed.atomTemplate._createInstance. Renamed toatomTemplate._instantiate. Zedux uses this internally. You shouldn't use it manually.atomTemplate._config. Renamed toatomTemplate.c(short forconfig). This is for internal use so it's obfuscatedatomTemplate._value. Renamed toatomTemplate.v(short forvalueOrFactory). This is for internal use.