@xstate/react 7.0.0-alpha.1 → 7.0.0-alpha.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -24,10 +24,10 @@ const toggleMachine = createMachine({
24
24
  initial: 'inactive',
25
25
  states: {
26
26
  inactive: {
27
- on: { TOGGLE: 'active' }
27
+ on: { TOGGLE: { target: 'active' } }
28
28
  },
29
29
  active: {
30
- on: { TOGGLE: 'inactive' }
30
+ on: { TOGGLE: { target: 'inactive' } }
31
31
  }
32
32
  }
33
33
  });
@@ -44,3 +44,9 @@ export const Toggler = () => {
44
44
  );
45
45
  };
46
46
  ```
47
+
48
+ ## Observing an actor
49
+
50
+ <!-- observer lifecycle from src/useActorRef.ts -->
51
+
52
+ Pass a snapshot listener or observer as the third argument to `useActorRef(logic, options, observer)`. It subscribes before the actor starts. When changing the machine configuration replaces the actor, the observer follows the replacement, even when the callback identity stays the same. Unmounting unsubscribes the observer and stops the actor.
@@ -4,6 +4,7 @@ export declare function useIdleActorRef<TLogic extends AnyActorLogic>(logic: TLo
4
4
  [K in RequiredActorOptionsKeys<TLogic>]: unknown;
5
5
  }
6
6
  ], IsNotNever<RequiredActorOptionsKeys<TLogic>>>): [Actor<TLogic>, (actorRef: Actor<TLogic>) => void];
7
+ export declare function useActorLifecycle<TLogic extends AnyActorLogic>(actorRef: Actor<TLogic>, setActorRef: (actorRef: Actor<TLogic>) => void, createReplacement: () => Actor<TLogic>): void;
7
8
  export declare function useActorRef<TLogic extends AnyActorLogic>(machine: TLogic, ...[options, observerOrListener]: IsNotNever<RequiredActorOptionsKeys<TLogic>> extends true ? [
8
9
  options: ActorOptions<TLogic> & {
9
10
  [K in RequiredActorOptionsKeys<TLogic>]: unknown;
@@ -48,40 +48,56 @@ function useIdleActorRef(logic, ...[options]) {
48
48
 
49
49
  // TODO: consider using `useAsapEffect` that would do this in `useInsertionEffect` is that's available
50
50
  useIsomorphicLayoutEffect__default["default"](() => {
51
- actorRef.logic.implementations = logic.implementations;
51
+ actorRef.logic.sources = logic.sources;
52
52
  });
53
53
  return [actorRef, setActorRef];
54
54
  }
55
- function useActorRef(machine, ...[options, observerOrListener]) {
56
- const [actorRef, setActorRef] = useIdleActorRef(machine, options);
55
+ function useActorLifecycle(actorRef, setActorRef, createReplacement) {
56
+ const pendingStopsRef = React.useRef(new Map());
57
57
  React.useEffect(() => {
58
- if (!observerOrListener) {
59
- return;
58
+ const cancelPendingStop = pendingStopsRef.current.get(actorRef);
59
+ if (cancelPendingStop) {
60
+ cancelPendingStop();
61
+ pendingStopsRef.current.delete(actorRef);
60
62
  }
61
- const sub = actorRef.subscribe(xstate.toObserver(observerOrListener));
62
- return () => {
63
- sub.unsubscribe();
64
- };
65
- }, [observerOrListener]);
66
- React.useEffect(() => {
67
- // If the actor was stopped by a previous cleanup (e.g. strict mode),
68
- // create a fresh actor. The setActorRef triggers a re-render so
69
- // useSyncExternalStore re-subscribes to the new actor.
70
- // Only recreate if externally stopped ('stopped' status from XSTATE_STOP),
71
- // not if it completed naturally ('done'/'error' status).
63
+
64
+ // If the actor was stopped before this effect reconnected, create a fresh
65
+ // actor. A stopped actor cannot be restarted.
72
66
  if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
73
- const newActor = xstate.createActor(machine, actorRef.options);
74
- newActor.start();
67
+ const newActor = createReplacement();
75
68
  setActorRef(newActor);
76
- // No cleanup — the re-render will run this effect again with the
77
- // new actorRef (which is Running), registering the stop cleanup then.
78
69
  return;
79
70
  }
80
71
  actorRef.start();
81
72
  return () => {
82
- actorRef.stop();
73
+ let canceled = false;
74
+ const cancel = () => {
75
+ canceled = true;
76
+ };
77
+ pendingStopsRef.current.set(actorRef, cancel);
78
+ queueMicrotask(() => {
79
+ if (!canceled) {
80
+ actorRef.stop();
81
+ }
82
+ if (pendingStopsRef.current.get(actorRef) === cancel) {
83
+ pendingStopsRef.current.delete(actorRef);
84
+ }
85
+ });
83
86
  };
84
87
  }, [actorRef]);
88
+ }
89
+ function useActorRef(machine, ...[options, observerOrListener]) {
90
+ const [actorRef, setActorRef] = useIdleActorRef(machine, options);
91
+ React.useEffect(() => {
92
+ if (!observerOrListener) {
93
+ return;
94
+ }
95
+ const sub = actorRef.subscribe(xstate.toObserver(observerOrListener));
96
+ return () => {
97
+ sub.unsubscribe();
98
+ };
99
+ }, [actorRef, observerOrListener]);
100
+ useActorLifecycle(actorRef, setActorRef, () => xstate.createActor(machine, actorRef.options));
85
101
  return actorRef;
86
102
  }
87
103
 
@@ -197,18 +213,7 @@ function useActor(logic, ...[options]) {
197
213
  if (snapshotWithStatus?.status === 'error') {
198
214
  throw snapshotWithStatus.error;
199
215
  }
200
- React.useEffect(() => {
201
- if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
202
- const newActor = xstate.createActor(logic, options);
203
- newActor.start();
204
- setActorRef(newActor);
205
- return;
206
- }
207
- actorRef.start();
208
- return () => {
209
- actorRef.stop();
210
- };
211
- }, [actorRef]);
216
+ useActorLifecycle(actorRef, setActorRef, () => xstate.createActor(logic, options));
212
217
  return [actorSnapshot, actorRef.send, actorRef];
213
218
  }
214
219
 
@@ -48,40 +48,56 @@ function useIdleActorRef(logic, ...[options]) {
48
48
 
49
49
  // TODO: consider using `useAsapEffect` that would do this in `useInsertionEffect` is that's available
50
50
  useIsomorphicLayoutEffect__default["default"](() => {
51
- actorRef.logic.implementations = logic.implementations;
51
+ actorRef.logic.sources = logic.sources;
52
52
  });
53
53
  return [actorRef, setActorRef];
54
54
  }
55
- function useActorRef(machine, ...[options, observerOrListener]) {
56
- const [actorRef, setActorRef] = useIdleActorRef(machine, options);
55
+ function useActorLifecycle(actorRef, setActorRef, createReplacement) {
56
+ const pendingStopsRef = React.useRef(new Map());
57
57
  React.useEffect(() => {
58
- if (!observerOrListener) {
59
- return;
58
+ const cancelPendingStop = pendingStopsRef.current.get(actorRef);
59
+ if (cancelPendingStop) {
60
+ cancelPendingStop();
61
+ pendingStopsRef.current.delete(actorRef);
60
62
  }
61
- const sub = actorRef.subscribe(xstate.toObserver(observerOrListener));
62
- return () => {
63
- sub.unsubscribe();
64
- };
65
- }, [observerOrListener]);
66
- React.useEffect(() => {
67
- // If the actor was stopped by a previous cleanup (e.g. strict mode),
68
- // create a fresh actor. The setActorRef triggers a re-render so
69
- // useSyncExternalStore re-subscribes to the new actor.
70
- // Only recreate if externally stopped ('stopped' status from XSTATE_STOP),
71
- // not if it completed naturally ('done'/'error' status).
63
+
64
+ // If the actor was stopped before this effect reconnected, create a fresh
65
+ // actor. A stopped actor cannot be restarted.
72
66
  if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
73
- const newActor = xstate.createActor(machine, actorRef.options);
74
- newActor.start();
67
+ const newActor = createReplacement();
75
68
  setActorRef(newActor);
76
- // No cleanup — the re-render will run this effect again with the
77
- // new actorRef (which is Running), registering the stop cleanup then.
78
69
  return;
79
70
  }
80
71
  actorRef.start();
81
72
  return () => {
82
- actorRef.stop();
73
+ let canceled = false;
74
+ const cancel = () => {
75
+ canceled = true;
76
+ };
77
+ pendingStopsRef.current.set(actorRef, cancel);
78
+ queueMicrotask(() => {
79
+ if (!canceled) {
80
+ actorRef.stop();
81
+ }
82
+ if (pendingStopsRef.current.get(actorRef) === cancel) {
83
+ pendingStopsRef.current.delete(actorRef);
84
+ }
85
+ });
83
86
  };
84
87
  }, [actorRef]);
88
+ }
89
+ function useActorRef(machine, ...[options, observerOrListener]) {
90
+ const [actorRef, setActorRef] = useIdleActorRef(machine, options);
91
+ React.useEffect(() => {
92
+ if (!observerOrListener) {
93
+ return;
94
+ }
95
+ const sub = actorRef.subscribe(xstate.toObserver(observerOrListener));
96
+ return () => {
97
+ sub.unsubscribe();
98
+ };
99
+ }, [actorRef, observerOrListener]);
100
+ useActorLifecycle(actorRef, setActorRef, () => xstate.createActor(machine, actorRef.options));
85
101
  return actorRef;
86
102
  }
87
103
 
@@ -200,18 +216,7 @@ function useActor(logic, ...[options]) {
200
216
  if (snapshotWithStatus?.status === 'error') {
201
217
  throw snapshotWithStatus.error;
202
218
  }
203
- React.useEffect(() => {
204
- if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
205
- const newActor = xstate.createActor(logic, options);
206
- newActor.start();
207
- setActorRef(newActor);
208
- return;
209
- }
210
- actorRef.start();
211
- return () => {
212
- actorRef.stop();
213
- };
214
- }, [actorRef]);
219
+ useActorLifecycle(actorRef, setActorRef, () => xstate.createActor(logic, options));
215
220
  return [actorSnapshot, actorRef.send, actorRef];
216
221
  }
217
222
 
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { useEffect, useState, useCallback } from 'react';
2
+ import { useEffect, useState, useRef, useCallback } from 'react';
3
3
  import useIsomorphicLayoutEffect from 'use-isomorphic-layout-effect';
4
4
  import { toObserver, createActor } from 'xstate';
5
5
  import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
@@ -22,40 +22,56 @@ function useIdleActorRef(logic, ...[options]) {
22
22
 
23
23
  // TODO: consider using `useAsapEffect` that would do this in `useInsertionEffect` is that's available
24
24
  useIsomorphicLayoutEffect(() => {
25
- actorRef.logic.implementations = logic.implementations;
25
+ actorRef.logic.sources = logic.sources;
26
26
  });
27
27
  return [actorRef, setActorRef];
28
28
  }
29
- function useActorRef(machine, ...[options, observerOrListener]) {
30
- const [actorRef, setActorRef] = useIdleActorRef(machine, options);
29
+ function useActorLifecycle(actorRef, setActorRef, createReplacement) {
30
+ const pendingStopsRef = useRef(new Map());
31
31
  useEffect(() => {
32
- if (!observerOrListener) {
33
- return;
32
+ const cancelPendingStop = pendingStopsRef.current.get(actorRef);
33
+ if (cancelPendingStop) {
34
+ cancelPendingStop();
35
+ pendingStopsRef.current.delete(actorRef);
34
36
  }
35
- const sub = actorRef.subscribe(toObserver(observerOrListener));
36
- return () => {
37
- sub.unsubscribe();
38
- };
39
- }, [observerOrListener]);
40
- useEffect(() => {
41
- // If the actor was stopped by a previous cleanup (e.g. strict mode),
42
- // create a fresh actor. The setActorRef triggers a re-render so
43
- // useSyncExternalStore re-subscribes to the new actor.
44
- // Only recreate if externally stopped ('stopped' status from XSTATE_STOP),
45
- // not if it completed naturally ('done'/'error' status).
37
+
38
+ // If the actor was stopped before this effect reconnected, create a fresh
39
+ // actor. A stopped actor cannot be restarted.
46
40
  if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
47
- const newActor = createActor(machine, actorRef.options);
48
- newActor.start();
41
+ const newActor = createReplacement();
49
42
  setActorRef(newActor);
50
- // No cleanup — the re-render will run this effect again with the
51
- // new actorRef (which is Running), registering the stop cleanup then.
52
43
  return;
53
44
  }
54
45
  actorRef.start();
55
46
  return () => {
56
- actorRef.stop();
47
+ let canceled = false;
48
+ const cancel = () => {
49
+ canceled = true;
50
+ };
51
+ pendingStopsRef.current.set(actorRef, cancel);
52
+ queueMicrotask(() => {
53
+ if (!canceled) {
54
+ actorRef.stop();
55
+ }
56
+ if (pendingStopsRef.current.get(actorRef) === cancel) {
57
+ pendingStopsRef.current.delete(actorRef);
58
+ }
59
+ });
57
60
  };
58
61
  }, [actorRef]);
62
+ }
63
+ function useActorRef(machine, ...[options, observerOrListener]) {
64
+ const [actorRef, setActorRef] = useIdleActorRef(machine, options);
65
+ useEffect(() => {
66
+ if (!observerOrListener) {
67
+ return;
68
+ }
69
+ const sub = actorRef.subscribe(toObserver(observerOrListener));
70
+ return () => {
71
+ sub.unsubscribe();
72
+ };
73
+ }, [actorRef, observerOrListener]);
74
+ useActorLifecycle(actorRef, setActorRef, () => createActor(machine, actorRef.options));
59
75
  return actorRef;
60
76
  }
61
77
 
@@ -174,18 +190,7 @@ function useActor(logic, ...[options]) {
174
190
  if (snapshotWithStatus?.status === 'error') {
175
191
  throw snapshotWithStatus.error;
176
192
  }
177
- useEffect(() => {
178
- if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
179
- const newActor = createActor(logic, options);
180
- newActor.start();
181
- setActorRef(newActor);
182
- return;
183
- }
184
- actorRef.start();
185
- return () => {
186
- actorRef.stop();
187
- };
188
- }, [actorRef]);
193
+ useActorLifecycle(actorRef, setActorRef, () => createActor(logic, options));
189
194
  return [actorSnapshot, actorRef.send, actorRef];
190
195
  }
191
196
 
@@ -1,5 +1,5 @@
1
1
  import * as React from 'react';
2
- import { useEffect, useState, useCallback } from 'react';
2
+ import { useEffect, useState, useRef, useCallback } from 'react';
3
3
  import useIsomorphicLayoutEffect from 'use-isomorphic-layout-effect';
4
4
  import { toObserver, createActor } from 'xstate';
5
5
  import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector';
@@ -22,40 +22,56 @@ function useIdleActorRef(logic, ...[options]) {
22
22
 
23
23
  // TODO: consider using `useAsapEffect` that would do this in `useInsertionEffect` is that's available
24
24
  useIsomorphicLayoutEffect(() => {
25
- actorRef.logic.implementations = logic.implementations;
25
+ actorRef.logic.sources = logic.sources;
26
26
  });
27
27
  return [actorRef, setActorRef];
28
28
  }
29
- function useActorRef(machine, ...[options, observerOrListener]) {
30
- const [actorRef, setActorRef] = useIdleActorRef(machine, options);
29
+ function useActorLifecycle(actorRef, setActorRef, createReplacement) {
30
+ const pendingStopsRef = useRef(new Map());
31
31
  useEffect(() => {
32
- if (!observerOrListener) {
33
- return;
32
+ const cancelPendingStop = pendingStopsRef.current.get(actorRef);
33
+ if (cancelPendingStop) {
34
+ cancelPendingStop();
35
+ pendingStopsRef.current.delete(actorRef);
34
36
  }
35
- const sub = actorRef.subscribe(toObserver(observerOrListener));
36
- return () => {
37
- sub.unsubscribe();
38
- };
39
- }, [observerOrListener]);
40
- useEffect(() => {
41
- // If the actor was stopped by a previous cleanup (e.g. strict mode),
42
- // create a fresh actor. The setActorRef triggers a re-render so
43
- // useSyncExternalStore re-subscribes to the new actor.
44
- // Only recreate if externally stopped ('stopped' status from XSTATE_STOP),
45
- // not if it completed naturally ('done'/'error' status).
37
+
38
+ // If the actor was stopped before this effect reconnected, create a fresh
39
+ // actor. A stopped actor cannot be restarted.
46
40
  if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
47
- const newActor = createActor(machine, actorRef.options);
48
- newActor.start();
41
+ const newActor = createReplacement();
49
42
  setActorRef(newActor);
50
- // No cleanup — the re-render will run this effect again with the
51
- // new actorRef (which is Running), registering the stop cleanup then.
52
43
  return;
53
44
  }
54
45
  actorRef.start();
55
46
  return () => {
56
- actorRef.stop();
47
+ let canceled = false;
48
+ const cancel = () => {
49
+ canceled = true;
50
+ };
51
+ pendingStopsRef.current.set(actorRef, cancel);
52
+ queueMicrotask(() => {
53
+ if (!canceled) {
54
+ actorRef.stop();
55
+ }
56
+ if (pendingStopsRef.current.get(actorRef) === cancel) {
57
+ pendingStopsRef.current.delete(actorRef);
58
+ }
59
+ });
57
60
  };
58
61
  }, [actorRef]);
62
+ }
63
+ function useActorRef(machine, ...[options, observerOrListener]) {
64
+ const [actorRef, setActorRef] = useIdleActorRef(machine, options);
65
+ useEffect(() => {
66
+ if (!observerOrListener) {
67
+ return;
68
+ }
69
+ const sub = actorRef.subscribe(toObserver(observerOrListener));
70
+ return () => {
71
+ sub.unsubscribe();
72
+ };
73
+ }, [actorRef, observerOrListener]);
74
+ useActorLifecycle(actorRef, setActorRef, () => createActor(machine, actorRef.options));
59
75
  return actorRef;
60
76
  }
61
77
 
@@ -171,18 +187,7 @@ function useActor(logic, ...[options]) {
171
187
  if (snapshotWithStatus?.status === 'error') {
172
188
  throw snapshotWithStatus.error;
173
189
  }
174
- useEffect(() => {
175
- if (actorRef._processingStatus === 2 /* ProcessingStatus.Stopped */ && actorRef.getSnapshot()?.status === 'stopped') {
176
- const newActor = createActor(logic, options);
177
- newActor.start();
178
- setActorRef(newActor);
179
- return;
180
- }
181
- actorRef.start();
182
- return () => {
183
- actorRef.stop();
184
- };
185
- }, [actorRef]);
190
+ useActorLifecycle(actorRef, setActorRef, () => createActor(logic, options));
186
191
  return [actorSnapshot, actorRef.send, actorRef];
187
192
  }
188
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xstate/react",
3
- "version": "7.0.0-alpha.1",
3
+ "version": "7.0.0-alpha.3",
4
4
  "description": "XState tools for React",
5
5
  "keywords": [
6
6
  "state",
@@ -54,7 +54,7 @@
54
54
  },
55
55
  "peerDependencies": {
56
56
  "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
57
- "xstate": "^6.0.0-alpha.1"
57
+ "xstate": "^6.0.0-alpha.59"
58
58
  },
59
59
  "peerDependenciesMeta": {
60
60
  "xstate": {
@@ -74,7 +74,7 @@
74
74
  "react-dom": "^19.0.0",
75
75
  "rxjs": "^7.8.1",
76
76
  "zod": "^3.25.51",
77
- "xstate": "^6.0.0-alpha.1"
77
+ "xstate": "^6.0.0-alpha.59"
78
78
  },
79
79
  "scripts": {}
80
80
  }