Skip to content

[@xstate/store] Fix synchronous updates to atom dependencies during a subscription callback#5513

Merged
davidkpiano merged 3 commits intostatelyai:mainfrom
thecrypticace:fix/atom-update-during-subscribe
May 2, 2026
Merged

[@xstate/store] Fix synchronous updates to atom dependencies during a subscription callback#5513
davidkpiano merged 3 commits intostatelyai:mainfrom
thecrypticace:fix/atom-update-during-subscribe

Conversation

@thecrypticace
Copy link
Copy Markdown
Contributor

Fix computed atom.subscribe() callbacks not re-running after one of it's dependencies is synchronously updated inside the callback. Previously, calling someDependency.set() inside a subscription callback would prevent that subscription from being notified of future changes. This also affected store selector subscriptions which triggered an update to the store (b/c they're also just computed atoms).


I ran into this when triggering an update to a store from a selector subscription that tracked cursor position and recorded (possibly) hovered items separately. I probably should've been using store events to trigger the secondary update but was trying to prototype things rather quickly

I found some workarounds while working on the fix:

  1. Update the dependent atom asynchronously using queueMicrotask
let atom = createAtom(() => count.get() * 2)

atom.subscribe(() => {
  queueMicrotask(() => count.set(v => v + 1))
})
  1. Manually call atom.get() inside the subscription callback after updating the dependent atom
let atom = createAtom(() => count.get() * 2)

atom.subscribe(() => {
  count.set(v => v + 1)
  atom.get()
})

This is what the fix does internally if the atom ends up dirty after the observer is notified.

  1. Subscribe to the atom again
let atom = createAtom(() => count.get() * 2)

atom.subscribe(() => {
  count.set(v => v + 1)
})

atom.subscribe(() => { /* do nothing */ })

The extra subscription causes atom.get() to get called again which cleans up after the first one's updates.

I don’t know that this situation is actually possible but I was concerned that calling `.get()` again might’ve introduced this behavior so I added a test for it.

Even though it didn’t infinite loop before it seems like a reasonable test to keep around.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 2, 2026

🦋 Changeset detected

Latest commit: 039f256

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@xstate/store Patch
@xstate/store-angular Patch
@xstate/store-preact Patch
@xstate/store-react Patch
@xstate/store-solid Patch
@xstate/store-svelte Patch
@xstate/store-vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@davidkpiano davidkpiano merged commit d15455b into statelyai:main May 2, 2026
1 check passed
@github-actions github-actions Bot mentioned this pull request May 2, 2026
@thecrypticace
Copy link
Copy Markdown
Contributor Author

So I realized this fix is incomplete (it works but doesn't catch all cases) 🤦‍♂️

When I worked on the original fix I was wondering if a pending check was necessary but couldn't figure out a situation that would actually trigger it. Well I accidentally ran into it earlier today after some code restructuring and it took a bit to turn into a reproduction. If you have a subscription running inside another subscription through multiple levels of atoms an atom can be marked as pending but not dirty.

This means the following updated test will fail currently:

it('allows updates to a computed dependency during a subscription callback', () => {
  const atom = createAtom({ a: 0, b: 0, c: 0 });

  const a0 = createAtom(() => atom.get().a);
  const a1 = createAtom(() => a0.get());
  a1.subscribe(() => atom.set((ctx) => ({ ...ctx, b: ctx.a })));

  const b0 = createAtom(() => atom.get().b);
  const b1 = createAtom(() => b0.get());
  b1.subscribe(() => atom.set((ctx) => ({ ...ctx, c: ctx.b })));

  atom.set((ctx) => ({ ...ctx, a: ctx.a + 1 }));
  atom.set((ctx) => ({ ...ctx, a: ctx.a + 1 }));
  atom.set((ctx) => ({ ...ctx, a: ctx.a + 1 }));

  expect(a0.get()).toBe(3);
  expect(a1.get()).toBe(3);
  expect(b0.get()).toBe(3);
  expect(b1.get()).toBe(3);
  expect(atom.get().a).toBe(3);
  expect(atom.get().b).toBe(3);
  expect(atom.get().c).toBe(3);
});

The fix is pretty simple: add the pending flag check or drop the if entirely. I can work around it pretty easily (a second empty subscription is enough) but gonna think over it some to see if there isn't a better fix / design — may open a PR tomorrow 👍

@davidkpiano
Copy link
Copy Markdown
Member

@thecrypticace No worries, would be happy to review the next PR.

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.

2 participants