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

support for system as actor #4677

Open
wants to merge 32 commits into
base: main
Choose a base branch
from
Open

Conversation

cevr
Copy link
Contributor

@cevr cevr commented Jan 13, 2024

This adds support for subscribing to an actor's system.

Motivation

XState is great for orchestrating complex changes within an app. This can eventually lead to having many levels of nested actors within a system.

The problem comes when trying to access an actor that is many levels deep. Things like actor.children.a.children.b.children.c become common, and that isn't accounting for ensuring that each child is present and latest. In React, this becomes quite tedious.

Fortunately, this is solved through using the root actor's system, since it's a flat registry of all actors within its scope (granted that unique systemIds are provided). The above instead becomes actor.system.get('c').

Unfortunately, there is no way to know when an actor has registered or unregistered within the system in a straightforward manner.

This PR seeks to fix that by adding support for subscribing to a system, providing updates on register/unregister events.

Here's an example of how this could be used to improve the React usecase:

before:

function useCActorRef() {
  const rootActorRef = useRootActorRef()
  const a = useSelector(rootActorRef, (state) => state.children.a);
  if (!a) {
    throw new Error('accessing a in the wrong state');
  }
  const b = useSelector(a, (state) => state.children.b);
  if (!b) {
    throw new Error('accessing b in the wrong state');
  }
  const c = useSelector(b, (state) => state.children.c);
  if (!c) {
    throw new Error('accessing c in the wrong state');
  }
  return c;
}

You could change the above to use empty actors if it's not defined, but it doesn't change the fact that you need to use 3 selectors to get the child c. Which is a lot syncing. Also, it may not be desirable if you rely state.value for some states rather than state.matches.

With the changes in this pr, an actor's system becomes an actor meaning you can pass it into useSelector.

and then rewrite the useCActorRef to look like this:

function useCActorRef() {
  const rootActorRef = useRootActorRef();
  return useSelector(rootActorRef.system, snapshot => snapshot.actors.c)
}

The benefit here is that there's only one subscription and it is easier to reason about.

Copy link

changeset-bot bot commented Jan 13, 2024

🦋 Changeset detected

Latest commit: 841d26c

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

This PR includes changesets to release 2 packages
Name Type
@xstate/react Minor
xstate Minor

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

Copy link

codesandbox-ci bot commented Jan 13, 2024

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.

packages/core/src/system.ts Outdated Show resolved Hide resolved
@@ -91,6 +106,8 @@ export interface ActorSystem<T extends ActorSystemInfo> {

export type AnyActorSystem = ActorSystem<any>;

const rootSubscriptionKey = '@xstate.system.root' as const;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a root subscription key that seemed unique enough while following some conventions I found.

This would be used in the case actor.system.subscribe(event => ...) was used with no systemId.


export interface ActorRegisteredEvent<
TActorRef extends AnyActorRef,
TSystemId extends string = string
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This made sense to me if there were any plans to allow for extracting systemId types from a machine.

Let me if it doesn't!

let listener =
typeof maybeSystemIdOrListener === 'function'
? maybeSystemIdOrListener
: maybeListener!;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure if this is okay, typescript would prevent this but there's no runtime check

? rootSubscriptionKey
: maybeSystemIdOrListener;

let subscriptions = systemSubscriptions.get(systemId) || new Set();
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could also design this differently by using a single set, and overriding the listeners if a systemId key is present. This would also mean passing the systemId directly in the event so its easier to parse

something like:

let listener = 
  typeof maybeSystemIdOrListener === 'function' ? 
  maybeSystemIdOrListener 
  : 
    (event) => { 
      if (event.systemId === maybeSystemIdOrListener) maybeListener(event)
     }

what do you think is preferable here?

completeListener
);

if (rootActor._processingStatus !== ProcessingStatus.Stopped) {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copied the implementation in createActor. I think it would make sense to keep this since system whose root actor is stopped won't be receiving updates, right?

reportUnhandledError(err);
}
break;
// can this error?
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What would an error look like here?

@cevr
Copy link
Contributor Author

cevr commented Jan 15, 2024

Disregard the resolved comments, did a refactoring to make it use the same logic as actor.subscribe for consistency

@Andarist
Copy link
Member

Please include some motivation behind this feature in the PR's description.

@cevr
Copy link
Contributor Author

cevr commented Jan 15, 2024

@Andarist Oops! Back to you

@Andarist
Copy link
Member

The motivation mentions that today it's tedious to access such actors in React but it doesn't show how this PR solves that. Could you include before/after comparison?

@cevr
Copy link
Contributor Author

cevr commented Jan 15, 2024

@Andarist You're right. Updated it with some examples!

@Andarist
Copy link
Member

I think this particular design doesn't fit XState well. When you subscribe to an actor you are subscribed to its snapshot values but here, when subscribing to the system, you are subscribed to events. A proposal that would bring this closer to how things work with actors would fit XState better.

@cevr
Copy link
Contributor Author

cevr commented Jan 15, 2024

@Andarist Fair enough. What are you thinking would be closer? There is inspect, which seems to also be a sort of subscription to events. Would it be better to appropriate inspect and make it also take RegistrationEvents? Currently it doesn't return a Subscription but that could be done rather easily.

What do you think?

@Andarist
Copy link
Member

The proposed implementation of useSystemActorRef shows how you are trying to create an implicit relationship between those events and the actor's existence in the system. I think a better design would be to decouple this from events completely and focus on what you are interested in and that's "the state" of the system.

Currently system isn't an Actor at all - but what would it take to make it one (or at least make it compatible with the ActorRef interface etc)? It would be best if you could do this:

const notifier = useSelector(actorRef.system, system => system.get('notifier'))

On the other hand, the system is mutable today and that doesn't play with useSelector so maybe this is a dead end right now.

Could you write down how you want to use this in your app? What's the rough sketch of the architecture that you have there and why do you find yourself in a position of reaching for an actor that might not exist at a time etc.

@cevr
Copy link
Contributor Author

cevr commented Jan 16, 2024

@Andarist I think this problem manifests itself only if you orchestrate the entirety of your application state in XState.

The best example I can give is one I face currently:

App has three states:

Cover | Menu | Checkout

Cover has a bunch of child states
Idle | Wizard | Auth | Complete

Menu has a couple of child states as well
Browsing | Item

And each child state can potentially have its own child state as well. These states are all machines that are instantiated by its parent machine using invoke. To get access to these machines requires the sort of boilerplate I mentioned in the motivation. This could be resolved by decoupling the machines from their parent states, and have them instantiated by React. It can also be resolved by having a flat registry of all actors within an actor's scope. I think option 2 is better, since it easier to reason about when looking at machine definitions how each state is related and it's already implemented via an actor's system.

The only missing piece to system is being able to observe changes to the system's registry. Which is what lead me to this PR.

@cevr
Copy link
Contributor Author

cevr commented Jan 16, 2024

I think option 3 would also be to have each child state just be part of the root machine rather than a separate machine that is invoked. This comes with added complexity in types, which tends to be a deal breaker as the app scales.

@cevr
Copy link
Contributor Author

cevr commented Jan 18, 2024

@Andarist What would you say the root issue is? Am I just holding xstate wrong, and should rather let React do the instantiations?

@Andarist
Copy link
Member

I think I stand by what I described in: #4677 (comment) . To improve the situation here we'd have to actor-ify the system further. Its current snapshot should be cached, each change in the registered actors should update it (in an immutable manner) and notify the subscribers about the change. This way you should be able to get this working:

-const notifier = useSelector(actorRef.system, system => system.get('notifier'))
+const notifier = useSelector(actorRef.system, s => s.actors.notifier)

@cevr
Copy link
Contributor Author

cevr commented Jan 25, 2024

@Andarist Sounds good to me. What would a snapshot look like in this case?

image

Would we move children, keyedActors, reversedKeyedActors into one object?

@cevr
Copy link
Contributor Author

cevr commented Jan 25, 2024

Also by making this an actor, do you mean to hook the system into the rootActor's update method?

Currently looking at useSelector implementations, and it seems it subscribes to the rootActor only. So the only way for these changes to be propagated would be through the rootActor unless we change the implementation of useSelector

@Andarist
Copy link
Member

Would we move children, keyedActors, reversedKeyedActors into one object?

Not necessarily. I'd like to keep the snapshot as lean as possible so it should only be extended with actors

Also by making this an actor, do you mean to hook the system into the rootActor's update method?

No. I'm sorry for not being explicit enough. I meant that its implementation should get closer to an actor - not necessarily that it should literally use Actor class/createActor factory, or hook into rootActor anyhow. It only has to be extended with subscribe and its snapshot has to be extended.

@cevr
Copy link
Contributor Author

cevr commented Jan 26, 2024

@Andarist back to you!

There are better tests that can be written, if you could give me some test cases you'd like to see I can get that done.

One question I have is: what would an error and complete call look like for an actor's system?

@cevr
Copy link
Contributor Author

cevr commented Feb 12, 2024

Anything I can do to get this past the finish line?

@cevr cevr changed the title add support for subscribing to system registration events support for system as actor Mar 2, 2024
@ccapndave
Copy link

Just a question - in useSelector(rootActorRef.system, snapshot => snapshot.actors.c), will snapshot.actors.c know what type it is?

@cevr
Copy link
Contributor Author

cevr commented Mar 15, 2024

unfortunately, not yet. the system is still untyped. you'd have to annotate it manually in the selector or in the function

@K3TH3R
Copy link

K3TH3R commented Apr 8, 2024

@cevr thank you for taking this on, being able to access deeply nested actors from anywhere in the system/UI was one of the biggest struggles I've had with building our app in XState (v4 mind you).

@cevr
Copy link
Contributor Author

cevr commented May 23, 2024

Anything I can do to improve this pr? @Andarist @davidkpiano

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.

4 participants