Investigate standardized XState integration #2195
Replies: 32 comments 1 reply
-
I have to look deeper into MST. Any state management solution is fully compatible with XState and The reason XState prefers immutable data structures for context is because it needs to control when context changes occur, and it needs to maintain history (just like Redux). Also, it's preferred change mechanism is property based (like React's By the way, I've been using foo: {
on: {
SOME_EVENT: {
actions: assign((ctx, e) => produce(ctx, draft => draft.count += e.value))
}
}
} So if MST works similarly, it can fit well. |
Beta Was this translation helpful? Give feedback.
-
MST is great at serializing and deserializing it's current state. I've created a few state machines using MST with a few I'm still having a hard time deciding how best to weave the tools together. |
Beta Was this translation helpful? Give feedback.
-
Here's an pretty rough POC of mst and xstate integration. https://github.com/RainerAtSpirit/xstatemst. |
Beta Was this translation helpful? Give feedback.
-
At one point, I was thinking of creating an MST model that would match xstate's data model. So I would model my |
Beta Was this translation helpful? Give feedback.
-
I'd rather like using the xstate interpreter for that job. .volatile(self => ({
xstate: createXStateAble(machineDefinition, {
actions: {
fetchData: self.fetchData,
updateData: self.updateData,
showErrorMessage: self.showErrorMessage
}
})
})) xstateable would have the minimum amount of props that a) the UI require and b) are needed to rehydrate the combination at any point in time. Something along the line. // createXStateAble .ts
import { Instance, types } from "mobx-state-tree"
import { Machine } from "xstate"
import { interpret } from "xstate/lib/interpreter"
export const createXStateAble = (machineDefinition: any, config: any) => {
const machine = Machine(machineDefinition, config)
const XStateable = types
.model("XStateable", {
machineDefinition: types.frozen(),
value: types.optional(types.string, ""),
nextEvents: types.array(types.string)
})
.volatile((self: any) => ({
machine
}))
.volatile((self: any) => ({
service: interpret(self.machine).onTransition(state => {
self.setValue(state.value)
self.setNextEvents(state.nextEvents)
})
}))
.actions((self: any) => ({
setValue(value: string) {
self.value = value
},
setNextEvents(nextEvents: any) {
self.nextEvents = nextEvents
},
afterCreate() {
self.value = self.machine.initialState.value
self.service.start()
}
}))
const xstate = ((window as any).xstate = XStateable.create({
machineDefinition
}))
return xstate
} I've updated https://github.com/RainerAtSpirit/xstatemst. accordingly. |
Beta Was this translation helpful? Give feedback.
-
Now that async recipes has landed in immer 2.0 immerjs/immer@5fee518, immer looks like a better companion for xstate to me. As @davidkpiano outlined is plays nicely with |
Beta Was this translation helpful? Give feedback.
-
Here is how nicely Immer (1.x) plays with XState. It's beautiful: |
Beta Was this translation helpful? Give feedback.
-
Immer meets xstate, here's my first pick: https://github.com/RainerAtSpirit/xstatemst/tree/immer. export const machine = Machine<
IPromiseMachineContext,
IPromiseMachineSchema,
PromiseMachineEvents
>(machineConfig, {
actions: {
fetchData: async function fetchData(ctx, event) {
const success = Math.random() < 0.5
await delay(2000)
if (success) {
// service is a singleton that will be started/stopped within <App>
service.send({
type: "FULFILL",
data: ["foo", "bar", "baz"]
})
} else {
service.send({ type: "REJECT", message: "No luck today" })
}
},
updateContext: assign((ctx, event) =>
produce(ctx, draft => {
switch (event.type) {
case "FULFILL":
draft.data = event.data
draft.message = ""
break
case "REJECT":
draft.message = event.message
break
}
})
)
}
})
export const service = interpret(machine) |
Beta Was this translation helpful? Give feedback.
-
Have been working with a MST-xstate integration last week, first results look very promising! Stay tuned :) |
Beta Was this translation helpful? Give feedback.
-
Pls integrate mobx too 🙏 |
Beta Was this translation helpful? Give feedback.
-
@mweststrate Is there any progress about XState integration? |
Beta Was this translation helpful? Give feedback.
-
Recently, used the following utility successfully in a project: import { types } from 'mobx-state-tree'
import { isObject } from 'util'
import { State } from 'xstate'
import { interpret } from 'xstate/lib/interpreter'
export function createXStateStore(machineInitializer) {
return types
.model('XStateStore', {
value: types.optional(types.frozen(), undefined), // contains the .value of the current state
})
.volatile(() => ({
currentState: null, // by making this part of the volatile state, the ref will be observable. Alternatively it could be put in an observable.box in the extend closure.
}))
.extend((self) => {
function setState(state) {
self.currentState = state
if (isObject(state.value)) {
self.value = state.value
} else {
self.value = { [state.value]: {} }
}
}
let machine
let interpreter
return {
views: {
get machine() {
return machine
},
get rootState() {
return self.currentState.toStrings()[0] // The first one captures the top level step
},
get stepState() {
const stateStrings = self.currentState.toStrings()
const mostSpecific = stateStrings[stateStrings.length - 1] // The last one is the most specific state
return mostSpecific.substr(this.rootState.length + 1) // cut of the top level name
},
matches(stateString = '') {
return self.currentState.matches(stateString)
},
},
actions: {
sendEvent(event) {
interpreter.send(event)
},
afterCreate() {
machine = machineInitializer(self)
interpreter = interpret(machine).onTransition(setState)
// *if* there is some initial value provided, construct a state from that and start with it
const state = self.value ? State.from(self.value) : undefined
interpreter.start(state)
},
},
}
})
} It worked great (feel free to create a lib out of it). For example with the following (partial) machine definition: import { address, payment, confirmation } from './states'
export const CheckoutMachineDefinition = {
id: 'checkout',
initial: 'address',
context: {
address: {
complete: false,
},
payment: {
complete: false,
},
},
meta: {
order: [
{
name: 'address',
title: 'Shipping',
continueButtonTitle: 'Continue To Payment',
event: 'CHANGE_TO_ADDRESS',
},
{
name: 'payment',
title: 'Payment',
continueButtonTitle: 'Continue To Review Order',
event: 'CHANGE_TO_PAYMENT',
},
{
name: 'confirmation',
title: 'Review',
continueButtonTitle: 'Place Order',
event: 'CHANGE_TO_CONFIRMATION',
},
],
},
states: {
address,
payment,
confirmation,
},
on: {
CHANGE_TO_ADDRESS: '#checkout.address.review',
CHANGE_TO_PAYMENT: '#payment',
CHANGE_TO_CONFIRMATION: '#confirmation',
},
} It can be tested / used like this: import { Machine } from 'xstate'
import { autorun } from 'mobx'
import { createXStateStore } from './index'
import { CheckoutMachineDefinition } from '../checkoutmachine'
describe('XStateStore', () => {
const testable = createXStateStore(() =>
Machine(CheckoutMachineDefinition, {
guards: {
validate: () => true,
},
}),
)
it('creates a default XStateStore', () => {
const test = testable.create({
machineDefinition: CheckoutMachineDefinition,
})
expect(test.toJSON()).toMatchSnapshot()
})
it('updates the value with a string', () => {
const test = testable.create({ value: 'address' })
expect(test.value).toEqual({ address: 'shipping' })
})
it.skip('updates the value with an empty object string', () => {
const test = testable.create({ value: { address: '' } })
expect(test.value).toEqual({ address: {} })
})
it('updates the value with an object', () => {
const test = testable.create({
value: {
address: {
shipping: {},
},
},
})
expect(test.value).toEqual({
address: {
shipping: {},
},
})
})
describe('stepState - ', () => {
it('one level', () => {
const test = testable.create({ value: 'payment' })
expect(test.value).toMatchInlineSnapshot(`
Object {
"payment": Object {},
}
`)
expect(test.matches('payment')).toBeTruthy()
})
it('two levels', () => {
const test = testable.create({
value: { address: 'shipping' },
})
expect(test.stepState).toBe('shipping')
expect(test.matches('address')).toBeTruthy()
expect(test.matches('address.shipping')).toBeTruthy()
})
it('three levels', () => {
const test = testable.create({
value: { billingAddress: { test1: { test2: {} } } },
})
expect(test.stepState).toBe('test1.test2')
})
})
describe('matches - ', () => {
it('undefined argument', () => {
try {
const test = testable.create({ value: { address: { review: { editBilling: 'billing' } } } })
} catch (e) {
expect(e).toMatchInlineSnapshot(`[Error: Child state 'review' does not exist on 'address']`)
}
})
it('full state', () => {
const test = testable.create({ value: { billingAddress: { test1: { test2: {} } } } })
expect(test.matches('billingAddress.test1.test2')).toBeTruthy()
})
it('parent state', () => {
const test = testable.create({ value: { billingAddress: { test1: { test2: {} } } } })
expect(test.matches('billingAddress')).toBeTruthy()
})
it('child state', () => {
const test = testable.create({ value: { billingAddress: { test1: { test2: {} } } } })
expect(test.matches('billingAddress.notThere')).toBeFalsy()
})
})
describe('transitions are reactive - ', () => {
it('value', () => {
const store = testable.create()
const states = []
autorun(() => {
states.push(store.value)
})
store.sendEvent('CHANGE_TO_ADDRESS')
store.sendEvent('CHANGE_TO_CONFIRMATION')
expect(states).toMatchSnapshot()
})
it('rootState', () => {
const store = testable.create()
const states = []
autorun(() => {
states.push(store.rootState)
})
store.sendEvent('CHANGE_TO_ADDRESS')
store.sendEvent('CHANGE_TO_CONFIRMATION')
expect(states).toMatchSnapshot()
})
it('matches', () => {
const store = testable.create()
const states = []
autorun(() => {
states.push(store.matches('address'))
})
store.sendEvent('CHANGE_TO_ADDRESS')
store.sendEvent('CHANGE_TO_CONFIRMATION')
expect(states).toMatchSnapshot()
})
})
}) So the MST store wraps around the machine definition and interpreter. It has two big benefits: One, the state is serializable as it is stored as a frozen, secondly, everything is observable, so using CC @mattruby |
Beta Was this translation helpful? Give feedback.
-
I'd be glad to create a small |
Beta Was this translation helpful? Give feedback.
-
Do one thing and do it well: My implementation of We get an observable string called "state" which responds to commands sent via .send() into the MST. Everything feels built into MST, so it behaves to an outside user simply as an YAO (yet another observable). Put this model into a map node on parent MST, and you can have 50 machines (for example rows in a spreadsheet), all running on their own having their own state, responding in real time to send() commands automagically via observable HOC wrapped JSX. Powerfull stuff... edit: did not notice that similar implementation was done few posts above just half a day before, we came to similar conclusions... this is a powerful pattern, could it be merged into MST core functionality? |
Beta Was this translation helpful? Give feedback.
-
@davidkpiano that would be awesome! Make sure to ping me if you have questions around MST or need review and such |
Beta Was this translation helpful? Give feedback.
-
@mattruby can you share an example or your current work? |
Beta Was this translation helpful? Give feedback.
-
I've used my implementation few comments above in a recent project while also onboarding the coworker to all of the principles behind XState and MobX and digged in many refactorings to simplify it as much as possible. The trick is in the fact you can make XState practically invisible (and less scary) to the outside, you just create a root model with:
From this point MST should take care of the view by itself when you call the action, everything happening at the root level. p.s.: I later upgraded my implementation a bit because I needed to pass the machine configuration dynamically into the model. This requires you to use a function Simpler alternatives are to just have a machine.init(config) call to trigger after MST is created or you can even hardcode the config directly into the type (this could work if there are only one or two possible machine configs in your project). Plugging the MSTMachine type into a call similar to Then it is a team decision if they prefer |
Beta Was this translation helpful? Give feedback.
-
We've tweaked it a little. But that's the core. |
Beta Was this translation helpful? Give feedback.
-
@davidkpiano when you say:
How do you check for immutability? I.e., is this a hard requirement (enforced), would returning a POJO break everything or both? |
Beta Was this translation helpful? Give feedback.
-
XState doesn't check for immutability, but it's just a best practice. Mutating context can break in unpredictable ways though (as with any mutation); e.g.... const machine = Machine({
context: { count: 0 },
// ...
});
const firstMachine = machine;
const secondMachine = machine; If you do I'm thinking of an API that may solve this though, via "late binding": // ⚠️ tentative future API
const machine = Machine({
context: () => ({ count: 0 }), // safe to mutate - completely new object!
// ...
}); |
Beta Was this translation helpful? Give feedback.
-
Solution is to define a map of machines: const Store = types
.model({
machines: types.map(MSTMachine)
})
.actions(store => ({
addMachine({ name, machineConfig }) {
store.machine.set(name, { machineConfig })
},
// everything below is optional, store will work without it but code won't be as nice
aClick() {
store.machine('a').send('click')
}
}))
.views(store => ({
machine(name) {
return store.machines.get(name)
},
get aState() {
return store.machine('a').state
}
}))
const store = Store.create()
store.addMachine({
name: 'a',
machineConfig : customMachineConfiguration
}) Then link callbacks: <button onClick={store.aClick}>send click to someName</button> And render their values: const Autorefresh = observable(_ => <div>{store.aState}</div>) Done. How much of this could be automated is to be discussed. It would be great if all getters and click handlers could generate themselves "automagically", because it is just copy paste boilerplate code... Keep it simple. State.value should be a scalar, not some complex conglomerate in the form of an object (don't even get me started on the mutability of it, problems with memory leaks, garbage collection, pointers getting passed around when juniors fall into the shallow cloning hole etc...). In short, state can be a boolean, integer, float or a string, that's it. If it is more complex, the machine configuration is probably already few 100 lines long. This is a clear sign you can refactor your machine configuration into submachines defining each discrete value separately (separation of concerns). If you already did that, great, you're 90% done. now just separate submachines into literally "separate machines". Aka: standalone variables. We are already using MST for the complex state because it is the best tool for it. We can create machines on this level, to manipulate each key in that state precisely. This is where XState shines. To illustrate the problem with an example: Toolbar of bold/italic/underline can be controlled with
|
Beta Was this translation helpful? Give feedback.
-
What is the status of this issue? Did everyone lose interest? |
Beta Was this translation helpful? Give feedback.
-
Afaik above solutions are still being used, just no one standardized it as
ready to use documented OSS package :)
…On Wed, Jan 5, 2022 at 12:29 AM Michael Stack ***@***.***> wrote:
What is the status of this issue?
Did everyone lose interest?
—
Reply to this email directly, view it on GitHub
<#1149 (comment)>,
or unsubscribe
<https://github.com/notifications/unsubscribe-auth/AAN4NBFRMBLFJ55WOL4BPGLUUOGF7ANCNFSM4GSCG3KQ>
.
Triage notifications on the go with GitHub Mobile for iOS
<https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
or Android
<https://play.google.com/store/apps/details?id=com.github.android&referrer=utm_campaign%3Dnotification-email%26utm_medium%3Demail%26utm_source%3Dgithub>.
You are receiving this because you were mentioned.Message ID:
***@***.***>
|
Beta Was this translation helpful? Give feedback.
-
We're currently using a derivative of both the solutions provided above since the first solution offered an easy way to add (to xstate) our existing MST actions, the second, offers the .matches property and solved an issue with nested machine states (swapping: It all plays pretty nicely, though be aware that if you use MST stores in the xstate guards, you can end up with race conditions. To avoid them you just need to ensure that you treat store updates (that are being used in guards) as xstate services, not xstate actions. |
Beta Was this translation helpful? Give feedback.
-
@Slooowpoke can you maybe share your final solution? |
Beta Was this translation helpful? Give feedback.
-
Still using a similar pattern as above, upgraded with the latest developments in the architecture: Moved the statecharts to the FAAS side where I am running "state transition as a service" on the BFF (backend for frontend) of micro frontends: sending the transition name in (by writing to a MST key monitored by onPatch which emits it to ws), getting state name back (BFF Redis holds the current state to know the context while keeping FAAS itself stateless). MST remains the perfect solution for receiving data in from the BFF in real time: websocket listener just triggers an action on MST, done. Everything on UI after that is handled automatically by observers. Now I am literally driving the frontend UI with XState - from the back seat - in FAAS. |
Beta Was this translation helpful? Give feedback.
-
@beepsoft Of course, here it is. I think we might be looking to move away from it though, there's been some issues with writing boilerplate to keep the two together. It might be an anti-pattern for xstate (and maybe mobx), but we've been using a fairly large state machine coupled together with a fairly large mobx-state-tree store. Ideally we'd have lots of smaller machines with final states, but coupling them (with the solution attached) would mean moving them all up to the top store and out of the machine (because we can't pass the services / actions / guards in easily). I'm sure theres a better solution but the ones I've come to have all felt a bit clunky, equally since MST doesn't reflect the flow types we have to define each one when declaring them in mobx which is error prone (we introduced helper functions for this and there is a PR I've raised which would clear this up). The solution I've been toying with recently is just using mobx with xstate. It uses an observable state as context and sits in front of the assign action, it gives the observability of mobx with everything else being the xstate way. I haven't tried it out much and it could probably be improved but it seems ok initially. I'd be super keen to see what you've ended up with @andraz, it could be that I've gotten it all twisted and maybe they are a match made in heaven! |
Beta Was this translation helpful? Give feedback.
-
What benefits have you been able to get by driving the frontend UI from the backend (instead of having the state machine stored on the frontend)? How has it worked out with versioning (potential version skew between frontend UI and backend state machine) ? |
Beta Was this translation helpful? Give feedback.
-
Hey folks, This is less of an issue and more of a discussion. I'm going to move it to GitHub discussions which will preserve the thoughts there, keep it discoverable, and allow people to chime in from time to time with additional thoughts and questions. Thanks for all the time, and if any of y'all are still interested in contributing to MobX-State-Tree, we are still around and happy to field PRs and such! |
Beta Was this translation helpful? Give feedback.
-
This is a subject I want to research soon, so any upfront ideas, inputs, examples are welcome!
cc @RainerAtSpirit @mattruby @davidkpiano
Beta Was this translation helpful? Give feedback.
All reactions