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

feat: jotai/xstate #289

Merged
merged 22 commits into from
Feb 20, 2021
Merged

feat: jotai/xstate #289

merged 22 commits into from
Feb 20, 2021

Conversation

dai-shi
Copy link
Member

@dai-shi dai-shi commented Feb 3, 2021

This is to add new lib jotai/xstate with xstate.

  • atomWithMachine implementation
  • tests
  • docs
  • examples

Note this PR is for the basic use case. It doesn't cover advanced use cases yet.

@codesandbox-ci
Copy link

codesandbox-ci bot commented Feb 3, 2021

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.

Latest deployment of this branch, based on commit 2a44c15:

Sandbox Source
React Configuration
React Typescript Configuration

@dai-shi dai-shi closed this Feb 3, 2021
@dai-shi dai-shi reopened this Feb 3, 2021
@dai-shi
Copy link
Member Author

dai-shi commented Feb 3, 2021

@Aslemammad
Copy link
Member

Aslemammad commented Feb 3, 2021

It's a really genius idea, could we merge the createMachine with the atom itself?

@dai-shi
Copy link
Member Author

dai-shi commented Feb 4, 2021

could we merge the createMachine with the atom itself?

You mean passing machine config instead of machine itself. (yeah, like we do withjotai/query)
I suppose we would lose some capabilities xstate provides? After all, it's just one line difference.

@dai-shi dai-shi mentioned this pull request Feb 4, 2021
@dai-shi
Copy link
Member Author

dai-shi commented Feb 4, 2021

https://codesandbox.io/s/react-typescript-forked-0obdb?file=/src/App.tsx
OK, it's fixed and the toggle example is still working.

@dai-shi
Copy link
Member Author

dai-shi commented Feb 4, 2021

https://xstate.js.org/docs/packages/xstate-react/

useMachine(machine, options?)
useService(service)
useActor(actor, getSnapshot)
asEffect(action)
asLayoutEffect(action)

They have four more apis other than useMachine. as(Layout)Effect are not related.
Should we model service and actor in atoms too?

@davidkpiano
Copy link

@dai-shi Can you support send with 2 arguments too?

const [state, send] = useAtom(...);

// ...

send('SOME_EVENT', { value: 'whatever' })

@dai-shi
Copy link
Member Author

dai-shi commented Feb 4, 2021

@davidkpiano
I actually noticed that... This is very unfortunate, but that's fundamentally impossible from the ground api design.
What we can do is:

const [state, send] = useAtom(...);
send(['SOME_EVENT', { value: 'whatever' }])

Is this acceptable?


Of course, one could create a custom hook, if they want.

const useMachineAtom = (machineAtom) => {
  const [state, send] = useAtom(machineAtom)
  return [state, useCallback((...args) => send(args), [send])]
}

@davidkpiano
Copy link

@davidkpiano
I actually noticed that... This is very unfortunate, but that's fundamentally impossible from the ground api design.
What we can do is:

const [state, send] = useAtom(...);
send(['SOME_EVENT', { value: 'whatever' }])

Is this acceptable?

Of course, one could create a custom hook, if they want.

const useMachineAtom = (machineAtom) => {
  const [state, send] = useAtom(machineAtom)
  return [state, useCallback((...args) => send(args), [send])]
}

Interesting, but no worries. The send({ type: ... }) syntax should suffice.

@dai-shi
Copy link
Member Author

dai-shi commented Feb 5, 2021

Interesting, but no worries. The send({ type: ... }) syntax should suffice.

How can we support send({ type: ... }) syntax?

https://xstate.js.org/api/classes/interpreter.html#send

send(event: SingleOrArray<Event<TEvent>> | Event<TEvent>, payload?: EventData): State<TContext, TEvent, TStateSchema, TTypestate>

We need to pass the payload, correct?

@davidkpiano
Copy link

The payload gets passed in as part of the object:

send({ type: 'SOME_EVENT', value: 'something' })

@dai-shi
Copy link
Member Author

dai-shi commented Feb 5, 2021

So, does this look correct?

      const { type, ...payload } = eventWithPayload
      service.send(type, payload)

https://github.com/pmndrs/jotai/pull/289/files#diff-5145185464ef53b5787b627431409df8bb020b938bec4a7c0d0677a6aa2824f2R102-R103

@dai-shi
Copy link
Member Author

dai-shi commented Feb 6, 2021

Hm, looking at your example again, https://codesandbox.io/s/react-typescript-forked-7hyg9?file=/src/App.tsx
it's already working like that.
So, we don't need to destructure for payload.
The remaining question is types...

@dai-shi
Copy link
Member Author

dai-shi commented Feb 6, 2021

Here's my modified example: https://codesandbox.io/s/react-typescript-forked-kjmk8?file=/src/App.tsx

const defaultTextAtom = atom("edit me");
const editableMachineAtom = atomWithMachine((get) =>
  createEditableMachine(get(defaultTextAtom))
);

We can get some values from atoms to feed into machine creation.
This would be good for some use cases. If we need values from props, we still need to use useMemo as before.

@dai-shi
Copy link
Member Author

dai-shi commented Feb 6, 2021

@dai-shi
Copy link
Member Author

dai-shi commented Feb 10, 2021

Although what's ready is minimal, I suppose it's good for the initial release.
It would be nice to get more feedbacks, so we'll see after the release.

@Andarist
Copy link

OK, I'm not sure about it. @davidkpiano 's example suggested to use props to initializing a machine (lazy init), so I assumed it would require to feed some values from outside, and it's atoms in our case. Props is also possible.

Right - feeding the values for a lazy init is OK. I've meant that beyond that it's possible to access atom values after that there using get and this is somewhat discouraged but I also don't think you are necessary in a position to fight against this. Just something I've noticed. Unless the design doesn't allow this and I've misunderstood this.

Yes, without knowing if it's correct. btw, i'm confused with the terms, service and interpreter.

If we are talking only about machines - they are kinda the same. A service could be of a different type but what is returned from interpret we tend to call both a service and an interpreter (it's an instance of Interpreter class).


Overall it looks good and the ability to share a machine atom between components easily is very nice. All of the consuming components will get subscribed to full state changes and in certain scenarios, this might be a perf issue. So I would try to figure out what is the best selector-like strategy for this (or maybe some proxy-based reactivity stuff - this seems to be your specialty 😉 )

@dai-shi
Copy link
Member Author

dai-shi commented Feb 20, 2021

I've meant that beyond that it's possible to access atom values after that there using get and this is somewhat discouraged but I also don't think you are necessary in a position to fight against this. Just something I've noticed. Unless the design doesn't allow this and I've misunderstood this.

Got it. The design doesn't allow this, but the current code doesn't prevent the misusage. I will add a guard.

If we are talking only about machines - they are kinda the same. A service could be of a different type but what is returned from interpret we tend to call both a service and an interpreter (it's an instance of Interpreter class).

Thanks. It requires some more time to learn these new things. So far, what I see is:

useMachine: machine config -> machine state
useInterpret: machine config -> service instance
useService: service instance -> machine(?) state

(still confused with actors...)

Overall it looks good and the ability to share a machine atom between components easily is very nice. All of the consuming components will get subscribed to full state changes and in certain scenarios, this might be a perf issue.

To address it, jotai already has so-called derived atoms.

const machineAtom = atomWithMachine(...)
const fooOfMachineAtom = atom((get) => get(machineAtom).foo)

We have selectAtom too.


Thanks for your feedback. I will release this shortly, but please let us know if you notice anything and we can follow up.

@dai-shi
Copy link
Member Author

dai-shi commented Feb 20, 2021

@dai-shi dai-shi merged commit 50a7642 into master Feb 20, 2021
@dai-shi dai-shi deleted the feat/xstate branch February 20, 2021 12:47
@sebastienbarre
Copy link

sebastienbarre commented Jun 23, 2021

Great work, but I'm a little lost as to how to pass the machine options...

It seems that atomWithMachine() takes a second argument as the machine options?

However, these options are more often than not used to set the state machine services and actions, which perform the actual tasks (side effects). As such, they can be (and often are) asynchronous functions. Nowadays in React world, these are created using hooks as well. Say, useQuery() or useMutation() if you use Apollo GraphQL, useQuery() if you use React Query, etc.

So I'm struggling to see how you can construct the atom. In the codesandbox example:

const editableMachineAtom = atomWithMachine((get) =>
  createEditableMachine(get(defaultTextAtom))
);

the above code is happening outside a React component, so you can't have anything with hooks passed in the machine options, or you would get the dreaded " Invalid hook call. Hooks can only be called inside of the body of a function component"...

Am I missing something?

@dai-shi
Copy link
Member Author

dai-shi commented Jun 23, 2021

Hi, good to see someone trying it.
To be honest, I'm still to learn how to work with xstate.
You can assume this api is not super thorough, so feedback is very welcome.

However, these options are more often than not used to set the state machine services and actions,

How would you use those options, if you were to use useMachine? I'm not sure if I understand the analogy to useQuery.
Oh, useMachine has the second argument. So, is it like you don't pass the second argument to createMachine but only to useMachine?

@sebastienbarre
Copy link

@dai-shi the first argument to useMachine is usually considered the blue-print of the machine. It's the machinery that says: when you reach state A, call service onFoo, then trigger side effect action doBar. It's semantic, really, the big picture.

The wiring, what is actually being done, for example calling a service, a REST endpoint, etc, is usually passed as the second argument to useMachine. This is where you implement that the onFoo service is a call to fetch, and the doBar action is logging a string to a file.

This provides some decoupling, should you decide one day to change fetch to useQuery(), or logging to a third party service instead of a file. You would ONLY need to change the second parameter, but your entire state machine blue-print would remain exactly the same. And that blueprint is what can be tested online in the XState playground actually.

@dai-shi
Copy link
Member Author

dai-shi commented Jun 23, 2021

Thanks for clarification. Helps to get the general idea.

So, the reason you would want to pass options to useMachine instead of createMachine is to use props or other states in React components, right?
If you use useMachine in two separate components, they are separate machine instances, so passing options each makes sense.
atomWithMachine is to share a machine instance across components, so it shouldn't receive options at the hook level, right?

Now, if you assume useAtom+atomWithMachine is a replacement for useContext+useMachine, how would you solve your use case with useContext and single useMachine?

the above code is happening outside a React component, so you can't have anything with hooks passed in the machine options

Do you have any example we can discuss with a little concrete case? Doesn't have to be real, but a hypothetical/pseudo example.

@sebastienbarre
Copy link

sebastienbarre commented Jun 24, 2021

The reason people pass options to useMachine is to enforce that decoupling I referred to
But yes, you could pass props and states, I would assume. That's not what you would be passing here. You would be passing the result of calling a React hook.

To be honest, I'm not entirely sure I understand what you are doing in jotai/xstate, it's a bit over my head :) This is a lot of code for sharing a state machine between components, so I would think you are trying something more ambitious? Ultimately, given the problem I raised earlier, I ended up having to use useContext() today in our app, as was demonstrated by xstate's author himself. It's very easy. I quote:

const ServiceContext = React.createContext();

const App = ({ children }) => {
  const [_state, _send, service] = useMachine(someMachine);

  return (
    <ServiceContext.Provider value={service}>
      {children}
    </ServiceContext.Provider>
  );
}

// in a child...
const Child = () => {
  const service = React.useContext(ServiceContext);
  const [state, send] = useService(service);

  // ...
}

As for any example involving hooks. Essentially picture the fact that xstate side effects and actions triggered when you enter a state are often asynchronous functions that query some third party server for example. Now unfortunately, because of the React hooks craze, many of the popular "fetch"-style libraries like React Query, or React Apollo GraphQL now offer their React API as hooks. Meaning, you have to be inside a component when you essentially create the wiring between your app UI and a third-party service. The result of that hook is both the means to get the data, and the data itself once the async function under the hood has resolved, and that is what a UI component will use to display its contents.

So you see the issue here; all the Jotai examples feature atoms being created outside a component using atom() or atomWithMachine(), and accessed inside the component using useAtom(), a hook. Now simply imagine having to use atom() outside a component, as should be, but the initialization value foo in const myAtom = atom(foo);, is an object that needs to contain the result of calling a React hook. Essentially this is all there is to it: you want to be able to initialize an atom with a value that contains the result of a React hook.

I also found an example for your perusal.
From XState: The Solution to All Your App State Problems:

import { useMachine } from '@xstate/react'

...other code

const [state, send] = useMachine(usersMachine, {
  // This is where we pass in the "fetchUsers" function that we
  // referenced in the machine configuration
  services: {
    fetchUsers: () => fetch('some-endpoint')
      .then((res) => res.json())
      // When we resolve the promise here, we'll trigger a state transition
      // to the "success" state
      .then((res) => Promise.resolve(res.data))
      .catch((err) =>
      // When we reject the promise here, we'll trigger a state transition
      // to the "error" state
        Promise.reject({
          status: err.response.status,
          data: err.response.data
      })
    )
  }
})

const isLoading = state.matches('loading')
const isSuccess = state.matches('success')
const isError = state.matches('error')

const handleButtonClick = () => {
  send('FETCH')
}

...other code

Now you can see the second argument to useMachine() being the array of options that implement the side effects. In this example, good old fetch() is called, and that would "work", the machine could be constructed outside a component. Unfortunately, that's not what the kids do nowadays:

Hope this helps.
Ultimately I had to solve this problem quick and as much as I like Jotai (it's great), I ended up using the old React context and provider, aka useContext(). But it would be neat if it could be done with Jotai.

@dai-shi
Copy link
Member Author

dai-shi commented Jun 24, 2021

Thanks. I think I'm getting to understand what you are trying to say.

The reason people pass options to useMachine is to enforce that decoupling I referred to

Please note atomWithMachine is modeled after useMachine, not createMachine.
Basically, atomWithFoo + useAtom becomes useFoo. (ex. atomWithQuery + useAtom = useQuery.)

so I would think you are trying something more ambitious?

The goal is to have a machine in the atom world.

so you can't have anything with hooks passed in the machine options

so, like you said, your assumption is correct. please note that atom defined outside components, will run inside a component. but you are right. there's no way to interact with other state/props/hooks than atoms.
(And, one obvious issue is machine options can't take get. I'd need to fix it.)

In this example, good old fetch() is called, and that would "work", the machine could be constructed outside a component.

Yeah, this one works.

Unfortunately, that's not what the kids do nowadays:

But, not for others.

I agree the current atomWithMachine doesn't cover these use cases.
Let me think what we can do.

I ending using the old React context and provider, aka useContext()

Meanwhile, I would like to ask a question: How would you use RQ's useQuery with this old React context style?

const ServiceContext = React.createContext();

const App = ({ children }) => {
  const [_state, _send, service] = useMachine(someMachine);

  return (
    <ServiceContext.Provider value={service}>
      {children}
    </ServiceContext.Provider>
  );
}

// in a child...
const Child = () => {
  const service = React.useContext(ServiceContext);
  const [state, send] = useService(service);

  // ...
  // Do you mean we can do something here with useQuery()??
}

@sebastienbarre
Copy link

sebastienbarre commented Jun 24, 2021

Meanwhile, I would like to ask a question: How would you use RQ's useQuery with this old React context style?

Oh very easy, you are inside a component, you can create as many hooks as you want.

For example, here is a hook I wrote yesterday that I call, instead of calling useMachine(). The hook internally just calls useMachine() with the appropriate options:

import { gql, useMutation } from '@apollo/client';
import { createMachine } from 'xstate';

const START_TIMER_MUTATION = gql`...`
const STOP_TIMER_MUTATION = gql`...`

const timeChargingMachine = createMachine(
  {
...
    states: {
...
      STARTING_TIMER: {
        invoke: {
          src: 'startTimer',
...
        },
      STOPPING_TIMER: {
        invoke: {
          src: 'stopTimer',
...
        }

});

export function useTimeChargingStateMachine() {
  const [startTimerMutation] =  useMutation(START_TIMER_MUTATION);
  const [stopTimerMutation] =  useMutation(STOP_TIMER_MUTATION);

  const machineOptions = {
    services: {
      startTimer: (ctx, event) =>
        startTimerMutation({
          variables: { id: event.timer.id },
        }).then(({ data }) => (data.startTimer.userErrors !== null ? Promise.reject(data) : data)),
      stopTimer: (ctx, event) =>
        stopTimerMutation({
          variables: { id: event.timer.id },
        }).then(({ data }) => (data.stopTimer.userErrors !== null ? Promise.reject(data) : data)),
    },
  };

  return useMachine(timeChargingMachine, machineOptions);
}

Note how the machine options take care of binding the src parameter of each state to an actual side effect, which here is to call a GraphQL mutation.

Then in my App, I call useTimeChargingStateMachine() instead of useMachine(), so that it is entirely configured, and I stick the corresponding service instance in the context. But it's only possible because I'm doing so from inside a component.

This might be the smaller, visible part of the iceberg I'm afraid. If your atom is meant to be shared across UI components... then it is possible each component will want to have its own side effects (aka actions). This is currently not possible because the options are specified when calling useMachine(), after which the state machine service instance is "locked", it cannot be reconfigured (that I know of). Whereas one would possibly want to specify the side-effects when calling useService(), so that two components sharing the same state machine instance can have different side effects. That's just me and my little problems, but I'm giving you a heads up regarding that pitfall :) Xstate author might be considering this avenue, I'm not sure, we are discussing it in Same service, but different actions · Discussion #2330 · davidkpiano/xstate, the workaround per UI component is not super clean, but it seems to be working.

@dai-shi
Copy link
Member Author

dai-shi commented Jun 24, 2021

For example, here is a hook I wrote yesterday that I call, instead of calling useMachine(). The hook internally just calls useMachine() with the appropriate options:

Hmm, this is done in the outside of Context Provider? I thought you want it in the Child component.

Whereas one would possibly want to specify the side-effects when calling useService(), so that two components sharing the same state machine instance can have different side effects.

Okay, one machine instance and multiple services connected to the machine, right?
If so, it makes sense. Not sure how to represent the concept in atoms, though.
btw, is this the same as what you've been requesting? I'm a bit confused, if your request is about creating multiple machine instances in components from a single machine config.

I'm not sure, we are discussing it in Same service, but different actions · Discussion #2330 · davidkpiano/xstate

Hm, so services are separate instances?

Anyway, thanks for all your help. I wish there would be a small codesandbox to play with. If there were something with useMachine, I could try converting it to (maybe modified) atomWithMachine.

@sebastienbarre
Copy link

sebastienbarre commented Jun 24, 2021

Hmm, this is done in the outside of Context Provider? I thought you want it in the Child component.

No, this is done inside. It's exactly the same example as I gave you, except I replaced useMachine() with my own useTimeChargingStateMachine(), but the only reason I can do so safely is because I'm inside a component (here, App):

const App = ({ children }) => {
  const [_state, _send, service] = useTimeChargingStateMachine(); 

  return (
    <ServiceContext.Provider value={service}>
      {children}
    </ServiceContext.Provider>
  );
}
...

Okay, one machine instance and multiple services connected to the machine, right?

No :) To quote xstate author's:

  • useMachine(someMachine) will create a new service instance of that machine (nothing is shared - someMachine is just a blueprint)
  • useInterpret(someMachine) will also create a new service instance
  • useService(someService) will use that shared service instance

Calling createMachine() doesn't really get you anywhere. For the machine to run, you need to interpret it, at which point you no longer have a state machine instance, you have a state machine service instance. This can be done with either useMachine() or useInterpret(). This is all explained in Interpreting Machines | XState Docs. I quote:

While a state machine/statechart with a pure .transition() function is useful for flexibility, purity, and testability, in order for it to have any use in a real-life application, something needs to:

  • Keep track of the current state, and persist it
  • Execute side-effects
  • Handle delayed transitions and events
  • Communicate with external services

The interpreter is responsible for interpreting the state machine/statechart and doing all of the above - that is, parsing and executing it in a runtime environment. An interpreted, running instance of a statechart is called a service.

The service is what you listen to using XState's useService(). This is what could be shared. If you were to use useMachine() with the exact same config again, you would be creating a new, separate service. Sending events to one would have no impact on the other, whereas sending one event to one service would be propagated to all the UI elements listening to that shared service.

Anyway, glad it helps. I'm sure useAtomMachine() can be used right now and is doing the right thing by caching the machine, as long as people do not need to initialize it with anything coming from a React hook, since this can't be done.

@dai-shi
Copy link
Member Author

dai-shi commented Jun 25, 2021

Hmm, this is done in the outside of Context Provider? I thought you want it in the Child component.

No, this is done inside. It's exactly the same example as I gave you, except I replaced useMachine() with my own useTimeChargingStateMachine(), but the only reason I can do so safely is because I'm inside a component (here, App):

const App = ({ children }) => {
  const [_state, _send, service] = useTimeChargingStateMachine(); 

  return (
    <ServiceContext.Provider value={service}>
      {children}
    </ServiceContext.Provider>
  );
}
...

Good. I meant this useTimeChargingStateMachine is outside ServiceContext.Provider.
There's a hope for this pattern to be supported with atoms.

Okay, one machine instance and multiple services connected to the machine, right?

No :) To quote xstate author's:

* `useMachine(someMachine)` will **create a new service instance** of that machine (nothing is shared - `someMachine` is just a blueprint)

* `useInterpret(someMachine)` will also create a new service instance

* `useService(someService)` will **use that shared service instance**

Yeah, I saw that.

The interpreter is responsible for interpreting the state machine/statechart and doing all of the above - that is, parsing and executing it in a runtime environment. An interpreted, running instance of a statechart is called a service.

This is good to know. So, what I called a instance is s a service.

Anyway, glad it helps. I'm sure useAtomMachine() can be used right now and is doing the right thing by caching the machine, as long as people do not need to initialize it with anything coming from a React hook, since this can't be done.

I see the whole point of your suggestion is this. And this is not only about atomWithMachine, but about any jotai atoms.

So, the fact is, when we need to create an atom that depends on props, define it in component.

const Component = ({ id }) => {
  const dataAtom = useMemo(() => atom({ id }), [id])
  const [data, setData] = useAtom(dataAtom)
  // ...
}

It's be the same for atomWithMachine.
I'd create a simple codesandbox to show how it would work and there are many techniques from there.
Is there any codesandbox example that you want me to convert with atomWithMachine?

@sebastienbarre
Copy link

I see the whole point of your suggestion is this. And this is not only about atomWithMachine, but about any jotai atoms.

Correct, I think we found the crux of the issue here.

So, the fact is, when we need to create an atom that depends on props, define it in component.

Wow, I never thought of using useMemo() to do it. Seems to me that would work, yes.

I think if you updated the doc to mention that specific use case, people who are familiar with React hooks would put two and two together and see the benefit right away, since they could use atomWithMachine the exact same way.

Thanks for the tip

@dai-shi
Copy link
Member Author

dai-shi commented Jun 25, 2021

Very good. I will see how I can update the docs.
Again, this is not just about jotai/xstate but more about the whole concept around atoms.
I wish the docs clarifies this more, but for now updating jotai/xstate docs would be a start.
Any help from anyone around docs would be appreciated.

I would love to create a codesandbox example, a simple one. Maybe an artificial one that updates a state from useState on xstate effects.

Thanks for your help.

@Andarist
Copy link

Wow, I never thought of using useMemo() to do it. Seems to me that would work, yes.

useMemo might not be the best solution for this because it doesn't come with semantic guarantees about caching - the underlying "cell" can be evicted anytime (which currently may happen during Fast Refresh). I believe that atoms should preserve their state in such situations so something like this might be a better fit: https://github.com/Andarist/use-constant

@dai-shi
Copy link
Member Author

dai-shi commented Jun 25, 2021

True in this case. Thanks for the catch.

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.

6 participants