Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Breaking] feat: synchronous atomEffect #62

Merged
merged 10 commits into from
Feb 18, 2025
Merged

[Breaking] feat: synchronous atomEffect #62

merged 10 commits into from
Feb 18, 2025

Conversation

dmaskasky
Copy link
Member

@dmaskasky dmaskasky commented Feb 10, 2025

Summary

This PR updates the behavior of atomEffect. In the past, atomEffect would run in the next microtask; now, it runs synchronously on mount and whenever its dependencies change. Batching is still supported when a writable atom updates multiple dependencies: those related state changes are batched together, and the effect only runs after the writable atom finishes its updates. This prevents partial updates and provides a more predictable, centralized way to handle downstream changes.

Example Usage

const syncEffect = atomEffect((get, set) => {
  get(someAtom)
  set(anotherAtom)
})

const store = createStore()
store.set(someAtom, (v) => v + 1)
console.log(store.get(anotherAtom)) // already updated by atomEffect

When someAtom is updated, the effect runs immediately and updates anotherAtom synchronously in the same task or microtask. This helps keep application state consistent and avoids race conditions.

When multiple atom dependencies are updated in a writable atom, atomEffect runs synchronously after all updates have been processed. This ensures atomEffect continues to support batching behavior. Please see the example below.

const onesAtom = atom(1)
const tensAtom = atom(10)

const writableAtom = atom(null, (get, set) => {
  set(onesAtom, (v) => v + 1)
  set(tensAtom, (v) => v + 10)
})

const changesAtom = atom([])

const logChanges = atomEffect((get, set) => {
  set(changesAtom, (v) => [get(tensAtom) + get(onesAtom), ...v])
})

const store = createStore()
store.sub(effect, () => {})
store.set(writableAtom)
store.get(logChanges) // [11, 22]

Migrating to v2

For most cases, you won't need to do anything.
Please keep reading if your logic relies on running the effect with a microtask delay or on batching updates.

Adding back the microtask delay

before

const effect = atomEffect((get, set) => {
  console.log('effect')
  return () => {
    console.log('cleanup')
  }
})

after

const effect = atomEffect((get, set) => {
  queueMicrotask(() => {
    console.log('effect')
  })
  return () => {
    queueMicrotask(() => {
      console.log('cleanup')
    })
  }
})

Batching updates

The atomEffect V2 only batches updates inside a writable atom.
before

const effect = atomEffect((get, set) => {
  console.log(get(atomA), get(atomB))
})

store.set(atomA, (v) => v + 1)
store.set(atomB, (v) => v + 1)

after

const effect = atomEffect((get, set) => {
  console.log(get(atomA), get(atomB))
})

const actionAtom = atom(null, (get, set) => {
  set(atomA, (v) => v + 1)
  set(atomB, (v) => v + 1)
})

store.set(actionAtom, (v) => v + 1)

Description of Operation

atomEffect uses two atoms

  • ref atom
  • effect atom

The ref atom stores the state needed for use inside the effect atom's read function, which includes:

  • dependencies - These are the dependencies collected since the last run. Because atomState.d is cleared in readAtomState, and the effect effect does not run during readAtomState, it's necessary to "remind" Jotai of these dependencies by calling get on each. Storing them separately from atomState addresses this need.
  • atomState - This is the atomState of the effect atom. Its epochis incremented to force Jotai to run the onAtomChange hook whenever the effect atom's dependencies change.

When the effect atom is subscribed, its atomState is created for the first time, causing onInit to fire. A significant portion of the complexity in atomEffect resides in this hook. onInit provides the scope where the runEffect function and several state variables are declared (details on runEffect below).

Within onInit, several store "building blocks" are also extracted. These are used to create a custom getter and setter, as well as to inoke internal functions necessary for atomEffect's operation. For example, ensureAtomState retrieves the effect atom's atomState.

The ensureSyncEffect function checks if the syncEffectChannel - a set of runEffects and runCleanups - has already been created for the store. This channel is where effects and cleanups are queued for execution in onStoreFlush.

From the store, we also get storeHooks, which support adding hooks for onAtomChange, onAtomMount, onAtomUnmount, and onStoreFlush. These hooks are used to add runEffect (on atom mount or change), or runCleanup (on atom unmount) to the syncEffectChannel, which will be invoked in onStoreFlush. Currently, there is no mechanism to remove the listeners from these store hooks.


The runEffect function

runEffectis responsible for executing the effect and managing both synchronous and asynchronous operations for the custom getter and setter. It returns early in several edge cases (discussed later). On each run, dependencies are cleared so they can be reassessed for that run.

Custom getter and setter are needed because:

  • We run them in synchronous mode while the effect runs.
  • We switch to asynchronous mode after the effect completes.
  • We run them synchronously again during the cleanup phase.
  • We switch back to asynchronous mode after the cleanup.

Custom Getter

  • Tracks dependencies during the synchronous effect run to "remind" Jotai of collected dependencies.
  • Does not track dependencies during asynchronous portion of effect or during cleanup.
  • The peak method on the getter is just store.get, which does not add atom dependencies.

Custom Setter

  • Increments an inProgress counter to prevent infinite loops when setting atoms that are themselves (or whose dependents are) included among the effectAtom’s dependencies. Any such recursion is blocked by an early return in runEffect.
  • The recurse method on the setter wraps the setter and then synchronously calls runEffect if the effect atom has changed. This recursion is intentional, and developers are responsible for avoiding infinite loops.
    • hasChanged is set to true when the atomOnChange storeHook runs (which occurs synchronously in the setter). Thus, by the time the finally block is reached, hasChanged will be true.
    • The recomputeInvalidatedAtoms call in the finally block ensures that Jotai’s atom graph is updated according to the change before runEffect is invoked again.

The runCleanup function

Before the effect is run, runCleanup may optionally be invoked. runCleanup independently manages the isSync variable to ensure the custom getter and setter run in synchronous mode during cleanup.


The effect Property

The effect is assigned as a property on the effect atom. This allows developers to change the effect of an effect atom after it has been initialized. The effect receives the custom getter and setter as parameters and may return a cleanup function. If a cleanup function is returned, it is passed to a new runCleanup call.


Unmounting

The Effect will continue to be responsive until the atomEffect unmounts. Onunmount the unmount storeHook runs. If runCleanup is a function, it gets added to the syncEffectChannel, to be processed in flushCallbacks.


This concludes the description of the operation of syncEffect.

@dmaskasky dmaskasky marked this pull request as draft February 10, 2025 04:28
Copy link

codesandbox-ci bot commented Feb 10, 2025

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

@dmaskasky dmaskasky force-pushed the sync-effect branch 3 times, most recently from 4a426e8 to 93d20dd Compare February 10, 2025 06:08
@dmaskasky dmaskasky changed the title Sync effect [Breaking] Synchronous atomEffect Feb 10, 2025
@dmaskasky dmaskasky marked this pull request as ready for review February 10, 2025 07:27
@dmaskasky dmaskasky changed the title [Breaking] Synchronous atomEffect [Breaking] feat: synchronous atomEffect Feb 11, 2025
@dmaskasky dmaskasky force-pushed the sync-effect branch 3 times, most recently from 231d80e to 0cb3cbb Compare February 11, 2025 23:55
@dmaskasky dmaskasky merged commit 9f4ac62 into main Feb 18, 2025
3 checks passed
@dmaskasky dmaskasky deleted the sync-effect branch February 18, 2025 04:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant