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.withScope
yourself, 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. fromInitializing
toActive
orActive
toDestroyed
).mutate
- fires when transactions are generated from asignal.mutate
call.
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.withScope
callback
inject
retrieves provided atoms and React contexts.

Other New Features
untrack
- A new top-level export for bumping out of a reactive context.- When called with no deps,
injectMemo
now 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.injectCallback
now wraps the callback inecosystem.withScope
(in addition toecosystem.batch
) if the injecting atom is scoped.- Atom APIs wrap exports in
ecosystem.batch
and, if the atom that's evaluating whenapi()
is called is scoped,ecosystem.withScope
. Disable this by passing{ wrap: false }
as the second parameter toapi.setExports
andapi.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.
The types for store atoms do have a few breaking changes:
AnyAtomInstance
is renamed toAnyStoreAtomInstance
AnyAtomTemplate
is renamed toAnyStoreAtomTemplate
Several other types in the stores package, like most of the Atom*Type
helpers, are deprecated. 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.
In particular, 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() }), {})
Migrating to Signals
To upgrade to v2 initially, you can simply replace most @zedux/react
imports with @zedux/stores
:
// before:
import { api, atom, injectMemo, injectStore } from '@zedux/react'
// after:
import { injectMemo } from '@zedux/react'
import { api, 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 { api, 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.getOnce
ecosystem.getInstance
->ecosystem.getNodeOnce
ecosystem.select
->ecosystem.getOnce
atomGetters.get
->ecosystem.get
atomGetters.getInstance
->ecosystem.getNode
atomGetters.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.reset
ecosystem.destroy
->ecosystem.reset
atomInstance.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.promiseStatus
atomInstance._promiseError
->atomInstance.promiseError
{ flags }
->{ tags }
(in atom config objects){ includeFlags, excludeFlags }
->{ includeTags, excludeTags }
(in the object passed toecosystem.dehydrate
)
For mods (previously added to ZeduxPlugin
s, now "ecosystem events" registered with ecosystem.on
), replace:
ecosystemWiped
->resetEnd
. Use in combination withresetStart
to capture/restore values.edgeCreated
->edge
. Use theevent.action
property to distinguish between edgeadd
,update
, andremove
edgeRemoved
->edge
(same note)evaluationFinished
->runEnd
. Use in combination withrunEnd
to time atom/selector evaluation time yourself.stateChanged
->change
statusChanged
->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:
atomGetters.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.getNode
injectAtomGetters
->injectEcosystem
Also optionally replace some deprecated and/or "legacy" types:
AtomGetters
->Ecosystem
AtomExportsType
->ExportsOf
AtomInstanceType
->NodeOf
AtomParamsType
->ParamsOf
AtomPromiseType
->PromiseOf
AtomStateType
->StateOf
Remove
manualHydration
atom config option.AtomInstanceBase
usages. This was an unnecessary superclass. TheAtomInstance
class itself now handles everything this class did.ZeduxPlugin
usages. Use ecosystem events instead.instanceReused
mods. No replacement. We're working on build tool plugins to do what this mod was trying to do. Those will be released later.- reading
sourceType
field on evaluation reasons. internalStore
usages. Zedux no longer stores ecosystems in module-level state. Pass them around yourself as needed.wipe
usages (the top-level export). Zedux no longer has internal state to clear.
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 imports from @zedux/stores
. Refer to this diff for the full list:
import {
// APIs:
api,
atom,
AtomApi,
AtomInstance,
AtomInstanceRecursive,
AtomTemplate,
AtomTemplateRecursive,
injectPromise,
injectStore,
ion,
IonTemplate,
// types:
AnyAtomApiGenerics,
AnyAtomGenerics,
AnyAtomApi,
- AnyAtomInstance,
+ AnyStoreAtomInstance,
- AnyAtomTemplate,
+ AnyStoreAtomTemplate,
AtomApiGenerics,
AtomApiGenericsPartial,
AtomApiPromise,
AtomEventsType,
AtomExportsType,
AtomGenerics,
AtomGenericsToAtomApiGenerics,
AtomInstanceType,
AtomParamsType,
AtomPromiseType,
AtomStateFactory,
AtomStateType,
AtomStoreType,
AtomValueOrFactory,
AtomTemplateType,
IonInstanceRecursive,
IonStateFactory,
IonTemplateRecursive,
- PartialAtomInstance,
+ PartialStoreAtomInstance,
SelectorGenerics,
- } from '@zedux/react' // or '@zedux/atoms'
+ } from '@zedux/stores'
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
injectHydration
and initialize/set signals yourself.
- use
atomInstance.getState
->atomInstance.getOnce
(but prefer.get
for automatic reactivity)atomInstance.setState
->atomInstance.set
atomInstance.setStateDeep
->atomInstance.mutate
- (if you previously migrated to using the
@zedux/stores
package):AnyStoreAtomInstance
-> back toAnyAtomInstance
- (if you previously migrated to using the
@zedux/stores
package):AnyStoreAtomTemplate
-> back toAnyAtomTemplate
Remove:
createStore
. Use mapped signals instead for composing state primitives. Useecosystem.signal
instead 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.get
for automatic reactivity)atomInstance.store.setState
->atomInstance.set
atomInstance.store.setStateDeep
->atomInstance.mutate
atomInstance.store.subscribe
->atomInstance.on
(see signal events above)
atomInstance.dispatch
. Can be replaced withatomInstance.send
for custom events- Same goes for
atomInstance.store.dispatch
. Replace withatomInstance.send
if 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.n
to 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. Thecycle
ecosystem event can be used to deleteecosystem.hydration
entries yourself when hydrated nodes becomeActive
._createdAt
(the property on atom and selector instances). Removed. Plugins can track time created themselves if needed.