Closed alita-moore closed 1 year ago
For reference this is notable in cases where I have multiple children to a single parent component that is managed via an xstate state machine. In these scenarios I may have a need to have a trigger on a certain state, but if the states are the same between the children then it's possible to have duplicate, conflicting, or unexpected outcomes.
For the time being I'm going to utilize service.state instead.
I might create another issue, but it seems this is also causing problems for a use cases where I want a utility that given a service will return a react mutable ref to the context of that service. This context will be null to start and will not update to the current state of the context until a change occurs. Thus, if the useMachineContext is mounted on a component for an already active service then there will be a period where the returned context is null but it should actually be defined. To mitigate this I use the service.current.state?.context to initialize the state with the current context. But this requires some instantaneous reference to the current state of the service. Thus, deprecating service.state would result in this workaround also breaking:
export function useMachineContext<
Service extends Interpreter<any, any, any, { value: any; context: any }>,
Context = Service extends Interpreter<
any,
any,
any,
{ value: any; context: infer C }
>
? C
: never
>(
service: React.MutableRefObject<Service>
): React.MutableRefObject<Context | null>;
export function useMachineContext<Context extends object>(
service: React.MutableRefObject<Interpreter<any, any, any>>
): React.MutableRefObject<Context | null>;
export function useMachineContext<Context extends object = never>(
service: React.MutableRefObject<Interpreter<any, any, any>>
): React.MutableRefObject<
| (Context extends never
? typeof service.current extends Interpreter<
any,
any,
any,
{ value: any; context: infer C }
>
? C
: never
: Context)
| null
> {
const contextRef = useRef<Context | null>(
// NOTE that service.current.state is deprecated, but there is no known alternative to initialize the value of the
// context. This is because the context is not available until the onChange handler is called, but that is only
// triggered when a change occurs. So without this, it will result in a null value for a service with a non-null
// context.
service.current.state?.context || null
);
useEffect(() => {
const handler: ContextChangeHandler = context => {
contextRef.current = context;
};
service.current.onChange(handler);
return () => {
service.current.off(handler);
};
}, [service.current, contextRef]);
// @ts-expect-error -- type object is not assignable to type Context extends never ? any : Context
return contextRef;
}
The same problem applies to the following react utility:
export const useCurrentMachineState = <States extends string>(
service: React.MutableRefObject<Interpreter<any, any, any>>
): React.MutableRefObject<States | null> => {
// Note that the following also uses the deprecated service.state property for the same reason as above.
const stateRef = useRef<States | null>((service.current.state?.value as States) || null);
useEffect(() => {
const handler: TransitionHandler = state => {
stateRef.current = state.value as States;
};
service.current.onTransition(handler);
return () => {
service.current.off(handler);
};
}, [service.current, stateRef]);
return stateRef;
};
Without the service.state it would not be possible (that I know of) to avoid an initial null value for the state, despite the machine having a non null state value.
@alita-moore Have you tried using service.getSnapshot()
instead of service.state
?
@davidkpiano that seems to work! My bad for missing it. Thanks for the heads up.
SOLUTION: use
service.getSnapshot()
instead ofservice.state
Description
There is a notable issue with the onTransition handler in xstate, where the state within the handler can become stale in the presence of a race condition. This can lead to unexpected behaviors, as components may operate based on outdated state information. Currently, using service.state within the onTransition handler mitigates this issue by ensuring access to the most recent state, but with the intended deprecation of service.state, this solution will no longer be viable.
In a CodeSandbox example provided (CodeSandbox Example), two components (ComponentHandlerA and ComponentHandlerB) both listen to state transitions and send an ACTIVATE event if the state is inactive. Despite one component changing the state to active, the other component, due to handling a stale inactive state, incorrectly sends another ACTIVATE event. This highlights the stale state issue within the handler and underscores the importance of service.state in ensuring access to the current state within the onTransition handler.
Expected result
inactive
, a component sends anACTIVATE
event to change the state toactive
.ComponentHandlerA
processes theinactive
state and sends theACTIVATE
event first, transitioning the state toactive
,ComponentHandlerB
should be notified of thisactive
state in its handler. Keep in mind that this means thatComponentHandlerB
would be notified twice. Once for theinactive
and again for theactive
state. But when it is notified for theinactive
state it should be aware of the current state which would beactive
.active
state inComponentHandlerB
, it should not send anotherACTIVATE
event, as the state is alreadyactive
.active
state and not attempt further state changes until another transition toinactive
occurs.Actual result
ComponentHandlerA
andComponentHandlerB
of theinactive
state.ACTIVATE
event if the state isinactive
.ComponentHandlerA
processes theinactive
state and sends theACTIVATE
event, transitioning the state toactive
.active
,ComponentHandlerB
still receives the staleinactive
state in its handler.ComponentHandlerB
incorrectly sends anotherACTIVATE
event based on the stale state information.ACTIVATE
events being sent, and both components repeatedly transitioning to theactive
state based on staleinactive
state information, causing unexpected and incorrect application behavior.Reproduction
https://codesandbox.io/p/sandbox/nostalgic-archimedes-vw2qsd
Additional context
In the provided CodeSandbox example, I've set up ComponentServiceC and ComponentServiceD to show the right way to handle this situation. They use service.state within the onTransition handler to always get the latest state.
Here,
Here, it properly notices that the state of the machine has changed and thus does not fire the second ACTIVATE event.