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
useService and side-effects #547
Comments
An idea I just had: const inputRef = useRef(null);
const [state, send] = useService(todoRef.overrideActions({
focusInput() {
inputRef.current && inputRef.current.select();
}
})) Actor refs could support a new method Or maybe we can pass an option to useEffect(() => {
todoRef.execute(state, {
focusInput() {
inputRef.current && inputRef.current.select();
}
},
{
// execute only `focusInput()` and ignore everything else
shallow: true
}
);
}, [state, todoRef]); |
I agree that there should be a different way to execute side-effects from live services, but conceptually overriding actions is impossible. A service can be something that lives anywhere (such as executing on some remote server) where you can't always "stop" actions that are going to execute. Instead, we can have a "partial execution" function that only executes actions where implementation is provided: // TENTATIVE API
import { execute } from 'xstate';
// ...
useEffect(() => {
// This will only execute the 'focusInput' action, and no other action
execute(state, {
focusInput() { /* ... */ }
});
}, [state]); Thoughts? |
@davidkpiano that was my second suggestion. Maybe we can name it |
I dont quite get the problem - what does it solve? Logging? Like - there is a need for smth extra over just useEffect? |
Not convinced that A better route might be via event dispatching; where the service would emit an "action" event and the component could subscribe to be notified when an external action should be fired. As you would to integrate with other event-dispatching APIs ( Supposing that a export function useAction(service, action, actionFn) {
useEffect(() => {
const listener = service.onAction(action, actionFn)
return () => listener.unsubscribe()
}, [service, action, actionFn])
} useAction(service, 'focusInput', () => inputRef.current.focus()) or const focusInput = useCallback(() => {
inputRef.current.focus()
}, [inputRef.current])
useAction(service, 'focusInput', focusInput) The event dispatching approach would also provide value beyond React + hooks, as it could be used similarly for "late-binding" external actions to a service in components for other frameworks. |
This approach is definitely better in my eyes - it makes integration with external scheduling system more apparent. Not sure though if "late-binding" is compliant with SCXML - executable content is always known at definition time. This doesn't mean per se that we can't inject implementation lately into such content (action implementation) but the action itself should be registered eagerly (at definition time). This might couple the service to a "child component" behavior, but maybe that's not necessarily bad - and there is always a possibility to split machines/actors so they stay more decoupled. Maybe there is a way to extend basic SCXML semantics with rules described in Extensibility of Executable Content, but would have to read through it more carefully to see if there is a way. |
John has a very good point, but I don’t think it’s gonna be an issue. The effects you gonna run within render() by using the useEffect hook are rendering ones, like focusing an input or scrolling the page. Even if Suspense/Concurrent Mode pauses the effect and cancels it instead of resuming, it won’t be a problem. Canceling the rendering effect could be the right thing to do, XState doesn’t have enough info to know that, so it delegates to the view library.
For effects that matter outside the presentation world, you can implement within the machine itself.
|
Sorry, on my phone, I’ll spam you guys a bit. 😅 Actually I think useEffect is the right choice for the kind of side effects we want to dispatch: rendering ones. It’s the UI framework that has more info to know some effect should run, be paused or cancelled. We don’t want to force XState to dispatch rendering effects that doesn’t matter for the UI anymore. |
Perhaps one way to think about it is that a named action that has no specified implementation would have an implicit implementation of firing an event. Or perhaps it could be better expressed as an explicit API? // NOTE: conjured "dispatch" out of thin air here - same API as send()
import { dispatch } from 'xstate'
const machine = Machine({
// ...
},
{
actions: {
focusInput: dispatch('focusInput')
}
}) (Where the interpreter would accept subscriptions for and dispatch the specified event.) Unlike actually reconfiguring the machine at runtime, one could argue this gives us some useful effects of late-binding (i.e. independent entities such as a React component are sent a message that they handle as they deem fit relative to their internal state) without it really being late binding (i.e. the behavior of the machine itself changing in any way). The implementation for the action is known at definition time - the named action will dispatch an event. |
It makes me uneasy that the limitations will be non-obvious and the behavior unpredictable. A
I definitely agree that ...but it also needs to know what actions were requested (aka messages were sent) in between the slices of time when it runs so that it has the rest of the information necessary to make those decisions. And the sanest way is to set up a channel of communication where the XState machine can tell it (because it's the only one that knows and a single current state snapshot won't be enough). Since Given your point, I think the body of my |
If you have the time, we could simulate and play around with the idea I was proposing above without any XState changes to see if there are showstoppers I'm not seeing. import mitt from 'mitt'
const machine = Machine({
context: {
emitter: mitt()
},
states: {
inactive: {
on: {
TRIGGER: {
target: 'active',
actions: ['focusInput']
}
}
}
},
// ...
}
},
{
actions: {
focusInput(context, event) {
context.emitter.emit('focusInput', /* ... */)
}
}
}) export function useEmitter(emitter, type, handler) {
useEffect(() => {
emitter.on(type, handler)
return () => emitter.off(type, handler)
}, [emitter, type, handler])
} useEmitter(context.emitter, 'focusInput', (event) => {
// whatever we should actually be doing here to schedule a potential focus()
}) |
Yep, I had smth like this in mind with "This doesn't mean per se that we can't inject implementation lately into such content (action implementation) but the action itself should be registered eagerly (at definition time).".
Yeah, this would probably need to be based on 2 effect callbacks cooperating, smth like: export function useAction(service, action, actionFn) {
const actionsRef = React.useRef([])
useEffect(() => {
const listener = service.onAction(action, (v) => {
actionsRef.push(v)
})
return () => listener.unsubscribe()
}, [service, action])
// no deps here, as we assume that we fire post-render actions?
React.useEffect(() => {
const currentActions = actionsRef.current
actionsRef.current = []
currentActions.forEach(v => actionFn(v))
})
} Because of the arbitrary time in which the second effect's callback might get called the cancellation mechanism should be supported as well. |
That's also a good point. 👍 I agree we should improve the behavior and make it more predictable. Unfortunately I'm very busy this week, but I'll try to find some time to play with your proposed API. |
All evidence to the contrary (so many words above!), I'm right there with you - busy week ahead. Best of luck with yours! |
I faced a similar problem. I needув to handle side effect in component when machine dispatch action. const useServiceSubscribe = (service: ServiceType, actionMap: SubscribeMap) => {
useEffect(() => {
const subscription = service.subscribe((state) => {
state.actions.forEach(action => {
if (actionMap[action.type]) {
actionMap[action.type](state.context);
}
});
});
return subscription.unsubscribe;
}, []);
}; In component i can use it useServiceSubscribe(service, {
'action1': (ctx) => {
// do smth
},
'action2': (ctx) => {
// do smth
},
}); Does this solve the described problem, or am I missing something? |
I'm running into the same problem with services when invoking a promise. The promise is being executed twice. The parent machine is responsible for invoking a promise and to change states depending on the response, while the child machine is responsible for sending the event to the parent. // inside the parent component, I inject some actions and ref.execute causes the service to be executed twice
useEffect(() => {
parentRef.execute(state, {
/** Some actions that are unrelated to the service that is being executed twice*/
});
}, [state, parentRef]); ( Any idea how I could prevent the service from firing twice? It's a HTTP request that upon sending a second time, the server will always reject, causing the @vizet would your solution also work with services or only actions? |
@CodingDive could u prepare a runnable repro case? |
Also, you can specify effects as the second argument: const [state, send] = useMachine(someMachine, {
actions: {
// your custom actions
},
// other custom options
}); Anything passed to |
Here is the repro. https://codesandbox.io/s/magical-cloud-pv0i1 |
@davidkpiano what do you mean with updated internally? Services might still be executed twice but they won't have stale values or something different? |
This happens because you are using execute with the latest state which holds to last executed actions and thus you are re-executing them: I wouldn't recommend this pattern of calling
It has just meant that you can do this: useMachine(someMachine, {
actions: {
someAction: () => /* reference react state or whatever, avoiding stale closure problem */
}
} |
Thank you for the clarification. I'd normally pass actions or services to the How could I pass some actions to the interpreter without reexecuting the service? Most actions I pass in there are not reliant on the machine state at all. E.g when focusing an input field using useRef. I believe this is also the example of the todoMvc but it obviously doesn't deal with 3 machines in a hierarchy and also no data fetching. |
When you're already working with a live service instance, you can't stop it from executing side-effects. It's a shared instance with side-effects that will execute, so you deciding to also If you want to share a service that does not execute side-effects (and instead hand that responsibility to child components), then you must model it as such. |
Well - if you made particular responsibility a part of a particular machine then I would say that it's much better to colocate this stuff with it. It's basically a similar problem (& solution) to React's lifting state up |
It's not that I necessarily want to use useEffect, I just want a way to pass an action to the live service. I guess as of now, I could either pass the action to the event of the grandParent machine which spawns the service. That feels rather dirty: Or move every action to the top of the hierarchy chain. What I don't like about this is that the topmost actor needs to define events and actions that are actually the responsibility of the child components and machines. Like the Now I also understand what this discussion is about as I've used many For my ideal API to solve this, I'd love the (live) // inside the TodoInput component which is responsible for managing the hypothetical todoInputMachine.
const inputRef = useRef<HtmlInputElement>(null)
const [state, send] = useService(
todoInputRef.withConfig({
actions: {
focusInput: (context, event, state) => void inputRef?.current?.focus(),
},
}),
);
return <input ref={inputRef} value={state.context.newTodo} onChange={(e) => void send({ type: ' NEW_TODO', input: e.target.value }) /> I think this would fix the stale state issue as the third param of the passed action is the stateDump of the service by the time the action is called. What do you think? My proposed API is quite similar to @hnordt
Wouldn't this be the responsibility of the machine or of the component that passes the actions to ensure the actions are stopped/never executed? I believe all my actions have been synchronous so far so I'm not aware how an action can be stopped once it's started. One thing I do is to send events that would trigger actions and if I need to, cancel them before they reach the machine (e.g with delayed events) so that the action is never executed in the first place. |
This kinda baffles me - maybe I don't understand the whole context that you have in mind. On the one hand - you want to have this managed inside Imagine implementation without considering React and how you structure its components etc. Would you do the same then? Would you try to decouple this implementation from the place where you have all the other pieces defined? I think that I wouldn't - this only introduces some indirection which in this instance, I feel, should be avoided. You are having those machines coupled to each other anyway because their concerns are highly connected - the one higher in the tree serves as a manager/supervisor to the inner ones (among other things). From what I understand - it has the knowledge about when this particular action should happen and it "just" needs it to execute. It seems like what you have mentioned - creating The main challenge though is still remaining with such - there is currently no clear answer how to have this There is a hidden gem that can solve this for you super nicely - you can actually pass That being said - I'm not 100% convinced (I have just literally figured out this solution right now) if we should overload parent like this, or to rephrase - if we should use @davidkpiano - WDYT? I think it would be great to include one of those in the docs. This unlocks some really nice patterns, the only question is - which one should be preferred? I'll give this more thought today as well. |
I was saying managing this within the todosMachine is the wrong place as the ref and correspondingly the action exists further down the component/machine hierarchy. In other words, if a Your solution of passing the parent service into the child component and onto Nonetheless, I'm pretty convinced that this needs to be solved on the actor level (without Another problem I see is that the parent no longer spawns the child machine itself and instead assigns an instance of the child upon receiving an event. // inside the parent machine
GREET: {
actions: assign((ctx, event, { _event }) => {
return {
childMachineRef: _event.origin
};
})
} This assumes that the child component which invokes the I'd like an API where we can colocate the action of a component by passing it to the actor it is encapsulating (just like demonstrated by your example) while still allowing the parent to be in full control when to |
I only skimmed the above comments (sorry!) but in general, I think there is a missed point about a machine vs. a service: A machine is a template for creating a service. When you call When calling Here's a loose analogy. If you were the director of a film, and the film hasn't been created yet, you can modify what should happen at certain points of the film, when certain effects should happen, etc. etc. But if the film was already shot and distributed and playing in theaters, no amount of yelling at the movie theater screen will change what happens in the film. You can definitely react to the film (and "execute actions" of your own), but you cannot change what happens. Hopefully that makes more sense. |
Thank you for the great analogy @davidkpiano. With the analogy in mind, whenever I wrote that I want to inject or pass an action into the live service, I should've said: "I want a way to react/subscribe to an action from within React." I want to use the example of this Codesandbox to make a point why this is fairly important especially since v1 of @xstate/react is just around the corner. As of now, there is no good API to react to an action without
This is obviously a very contrived example. Instead of setting local state, we could be performing a focus action or anything else. I can only see three alternatives with a built-in way to manage this. None of which are pretty. 1 Hoist the Tradeoffs:
2 Pass props down, send events up. The Tradeoffs:
src: (context, event) => someAsyncFunction().then((result) => result).catch((error) => Promise.reject({error, setErrorMessage: event.setErrorMessage}),
// just so I can use setErrorMessage as an action in the onError handler
onError: {
actions: (context, event) => event.data.setErrorMessage(event.data.error)
} 3 Pass the action to the I don't think this needs further discussion. This does not seem like a sane idea 😁 onError: {
actions: (context, event) => context.setErrorMessage(event.data)
} With the current alternatives out of the way, I also believe that I think from a DX perspective, I'd enjoy an API like the following the most. parentRef.subscribeActions({
SHOW_ERROR: (ctx, e) => {
setErrorMessage(e.data);
}
}) Alternatively, if it shouldn't be handled within the core. When using @xstate/react: useService(parentRef, {
subscribeToActions: {
SHOW_ERROR: (ctx, e) => {
setErrorMessage(e.data);
}
}
}) What do you think? |
Simpler idea: const ChildComponent = ({ parentService }) => {
const ref = React.useRef();
const [state] = useService(parentService);
// "React" to the "focusChild" action whenever it happens
useAction(state, "focusChild", () => {
// should probably be a memoized callback
ref.current.focus();
});
return <input ref={ref} />;
}; |
Keep in mind that you should not use XState's state as a dependency of an effect, because you might miss stuff when React decides to batch subsequent rerenders. A more safe version of this would look something like this: I still have to read carefully through @CodingDive's latest post so I can post a comment on all the mentioned concerns etc, but I would like to mention one thing now - I believe that the pattern presented by me is quite powerful and not that much of a hack (maybe besides "connecting" those 2 services in a parent-child relationship). It allows you to split the implementation, decoupling those 2 to some extent in a way that it, for example, allows you to codesplit them, whereas if you embed everything within a single machine definition (referencing other machines directly) you make codesplitting this harder. |
const ChildComponent = ({ childRef }) => {
const [state, send, childService] = useService(childRef);
useAction(service, "focusChild", () => {
ref.current.focus();
});
} Since it doesn't use @Andarist you can ignore my long posts from above then. With an API like
I believe it can be very powerful too. Pretty sweet that you found this to be possible! One thing I was worried about is the fact that the child component needs to render and invoke the child machine. Only then (upon having the actor greet the parent service) the child ref becomes available inside the parent service. This makes it difficult for the parent machine to work with the See the codesandbox or the code below // inside the ChildComponent we always need to call this for the child service to be wired to the parent service
useMachine(childMachine, { parent: parentService, /*...*/ })
// yikes, we're in the child component and assert the state of the parent service
if (
!parentService.state ||
!parentService.state.matches({
hasChildMachine: "wantToRenderChildComponent"
})
) {
return null;
}
// render actual child component elements Meanwhile, when using the spawn API, the 1, when to spawn the child machine const [state, send] = useMachine(parentMachine);
if (
state.matches({
hasChildMachine: "wantToRenderChildComponent"
})
) {
return <ChildComponent childRef={state.context.childRef} /> ;
} |
Closing. That's the final version based on the previous discussion: function useAction(service, actionType, callback) {
const latestCallbackRef = React.useRef(callback)
React.useEffect(() => {
latestCallbackRef.current = callback
})
React.useEffect(
() =>
service.subscribe((state) => {
if (!state.changed) {
return
}
if (!state.actions.find((action) => action.type === actionType)) {
return
}
latestCallbackRef.current()
}).unsubscribe,
[service, actionType]
)
} |
In the TodoMVC example,
Todo.jsx
file we have the following code:I didn't like that approach because all the state side-effects are going to be executed twice. Once by the interpreter, and again by the
useEffect
. You can add some console.logs totodoMachine
to confirm that. For example, allsendParent()
actions are gonna be executed twice.It's not a big issue, but it's more a workaround than a proper solution.
I don't know how to fix that, but I would like to open that GitHub issue to start some discussion.
The text was updated successfully, but these errors were encountered: