Closed fabiradi closed 2 years ago
This is a limitation and not a limitation (I'll explain why in a bit) but the workaround is to make the machine definition a "unit type" aka "literal" with as const
like this
I say "not a limitation" because you should almost never define the definition in a variable, for the various reason:
"foo"
is not assignable to "inactive" | "active"
useStateMachine({
initial: "a",
states: {
a: {
on: { X: "b" },
effect: ({ event }) => {
event.type; // event.type is "$$initial"
}
},
b: {
effect: ({ event }) => {
event.type; // event.type "X"
}
}
}
})
useStateMachine({
initial: "b", // <-- change here
states: {
a: {
on: { X: "b" },
effect: ({ event }) => {
event.type; // error because `event` is `never`
}
},
b: {
effect: ({ event }) => {
event.type; // event.type is "$$initial" | "X"
}
}
}
})
You can see it here. So the definition creates it's type from itself!
As you can see it says D extend Machine.Defintion<D>
so the constraint of D
is a derivative of itself! So you can't assign to a variable and get the same experience, because the definition needs to be a generic.
I just cannot figure how to apply them correctly.
You can't "apply" them unless you have a generic, there is no type without a generic hence you can't simply write let definition: SomeType
-- because SomeType
will always have a type parameter.
Oh wait, you can do it with an identity function haha because there you can make it a generic, wait let me show you.
import useStateMachine from "@cassiozen/usestatemachine";
import { Machine, A } from "@cassiozen/usestatemachine/dist/types";
const createDefinition =
<D extends Machine.Definition<D>>(definition: A.InferNarrowestObject<D>) =>
definition
const machine = createDefinition({
initial: 'inactive',
states: {
inactive: {
on: { TOGGLE: 'active' },
},
active: {
on: { TOGGLE: 'inactive' },
effect() {
console.log('Just entered the Active state');
// Same cleanup pattern as `useEffect`:
// If you return a function, it will run when exiting the state.
return () => console.log('Just Left the Active state');
},
},
},
})
const [state, send] = useStateMachine(machine)
There your problem is solved! And this will give you the exact same experience as if you were directly writing it in useStateMachine
(because the types are the same) -- so no disadvantages of using createDefinition
.
So I take back the as const
advice a better way to do this, I think we'll export a createDefinition
in the next release (subject to Cassio's agreement), but I'll personally still suggest you to write it directly in useStateMachine
but it's okay even if you use createDefinition
, it's a matter of taste I guess.
Oh wait I think you want to write in a variable so that you can write the machine outside the component so as to separate behavior from view, oh that makes sense haha in that case even I might write using createDefinition
-- so I also take back the writing directly in useStateMachine
suggestion xD -- in that case createDefinition
becomes even more important feature.
Thanks for your feedback! And also glad that you're liking the TypeScript support! (coz it's me who wrote the types :P)
I think we'll keep this issue open till we release a version with createDefinition
. I actually already had in my mind just forgot haha.
Thank you so much for taking the time to compile this detailed answer 👍🏻 . It explains the context very well and I can see a lot of similarities to xstate. It took me some time to try it again.
I had a lot of hope that I could, now, get it to work. However, there is a problem with effect()
that keeps me thinking. Auto-completion works well and the same "machine" also works without a problem in JS/ES6. As soon as the same is ported to TypeScript, it throws errors.
I'm sticking to the example from your readme to not include any mistakes that I might make without knowing.
import { Meta, Story } from '@storybook/react'
import useStateMachine, { t } from '@cassiozen/usestatemachine'
import { Machine, A } from '@cassiozen/usestatemachine/dist/types'
export default {
title: 'Spike/Machine',
} as Meta
const createDefinition = <D extends Machine.Definition<D>>(definition: A.InferNarrowestObject<D>) => definition
const machine = createDefinition({
initial: 'inactive',
schema: {
context: t<{ toggleCount: number }>(),
},
context: { toggleCount: 0 },
states: {
inactive: {
on: { TOGGLE: 'active' },
},
active: {
on: { TOGGLE: 'inactive' },
effect({ event, context, setContext }) { // 🚧 Adding arguments here causes the errors at useMachine(...)
console.log('Just entered the Active state', { context })
// Same cleanup pattern as `useEffect`:
// If you return a function, it will run when exiting the state.
return () => console.log('Just Left the Active state')
},
},
},
})
export const Overview: Story = () => {
const [state, send] = useStateMachine(machine) // ⛔️ <-- The error arises here (see below for error message)
return (
<>
<h3>Machine</h3>
<div>
{state.nextEvents.map(item => (
<button key={item} onClick={() => send(item)}>
{item}
</button>
))}
</div>
<pre>{JSON.stringify(state, null, 2)}</pre>
</>
)
}
The error text:
Argument of type 'InferNarrowestObject<Definition<{ initial: "inactive"; schema: { context: { [$$t]: { toggleCount: number; }; }; }; context: { toggleCount: number; }; states: { inactive: { on: { TOGGLE: "active"; }; }; active: { on: { TOGGLE: "inactive"; }; effect: unknown; }; }; }, { ...; }, { ...; }, true>>' is not assignable to parameter of type 'InferNarrowestObject<Definition<Definition<{ initial: "inactive"; schema: { context: { [$$t]: { toggleCount: number; }; }; }; context: { toggleCount: number; }; states: { inactive: { on: { TOGGLE: "active"; }; }; active: { on: { TOGGLE: "inactive"; }; effect: unknown; }; }; }, { ...; }, { ...; }, true>, { ...; }, un...'.
The types of 'states.inactive.effect' are incompatible between these types.
Type 'Effect<{ initial: "inactive"; schema: { context: { [$$t]: { toggleCount: number; }; }; }; context: { toggleCount: number; }; states: { inactive: { on: { TOGGLE: "active"; }; }; active: { on: { TOGGLE: "inactive"; }; effect: unknown; }; }; }, [...], "inactive"> | undefined' is not assignable to type 'Effect<Definition<{ initial: "inactive"; schema: { context: { [$$t]: { toggleCount: number; }; }; }; context: { toggleCount: number; }; states: { inactive: { on: { TOGGLE: "active"; }; }; active: { on: { TOGGLE: "inactive"; }; effect: unknown; }; }; }, { ...; }, { ...; }, true>, [...], "inactive"> | undefined'.ts(2345)
useMachine
.How can I use the great machine syntax? I really like the way, machines can be defined!
useMachine()
? This might be even simpler...const [state, send, definition] = useStateMachine(machine)
graph TD
start( ) --> inactive
inactive --TOGGLE--> active
active --TOGGLE--> inactive
Thanks for reporting you're providing really useful feedback!
It's a known typescript limitation (I forgot about this) that effect
s become unknown
(when using parameters) after you pass the definition to createDefinition
. One workaround is to replace effect: unknown
with effect: any
you can use this updated createDefinition
import { Machine, A } from '@cassiozen/usestatemachine/dist/types'
const createDefinition = <D extends Machine.Definition<D>>(definition: A.InferNarrowestObject<D>) =>
definition as MapEffectsToAny<D extends Machine.Definition<infer X> ? X : never>
type MapEffectsToAny<T> =
T extends object
? { [K in keyof T]: K extends "effect" ? any : MapEffectsToAny<T[K]> }
: T
Now your snippet compiles. Once we publish a version with createDefinition
it's going to be easy to write definitions outside.
Let me know if the issue still persists in someway.
🚀 Excellent! This solved it!
I needed to disable two eslint rules, but that is just a formal issue.
object
. Changing it to Record<string, unknown>
did not fix it, which usually works in most other cases.
Don't use
object
as a type. Theobject
type is currently hard to use (see this issue). Consider usingRecord<string, unknown>
instead, as it allows you to more easily inspect and use the keys.
any
. Changing it to unknown
also does not solve it.
Unexpected any. Specify a different type.
type MapEffectsToAny<T> =
T extends object // eslint-disable-line @typescript-eslint/ban-types
? { [K in keyof T]: K extends 'effect' ? any : MapEffectsToAny<T[K]> } // eslint-disable-line @typescript-eslint/no-explicit-any
: T
💡 I would suggest to keep this issue open until createDefinition
is included in a release. The eslint issues might not be relevant if hidden in the package anyways. It also depends on what rules are enabled.
Great! Yep as I too said we'll keep this issue open till we publish a version with createDefinition
exported.
Also needless to say right now you're using internals as a workaround, hence there's no guarantee that your code will not break with non-major bumps, so be careful. Once we export createDefinition
migrate to that.
Thanks again for your feedback!
Thanks both you guys for this issue. My use case involves React context (createContext) and TypeScript. What I had to do is the following:
import { createContext, useContext } from 'react'
import useStateMachine from '@cassiozen/usestatemachine'
import { Machine } from '@cassiozen/usestatemachine/dist/types'
const MultiSelectContext = createContext<MachineType | null>(null) // initialize context
// define machine outside so I can have a type (see MachineType down below)
const machine = {
initial: 'closed',
states: {
open: {
on: {
CLOSE: 'closed',
},
},
closed: {
on: {
OPEN: 'open',
},
},
},
} as const // as const because `useStateMachine` wouldn't work without it.
const Root = (props) => {
const stateMachine = useStateMachine(machine)
return (
<MultiSelectContext.Provider value={stateMachine} {...props} />
)
}
export const useMultiSelectMachine = () => {
const ctx = useContext(MultiSelectContext)
if (!ctx) {
throw new Error('MultiSelect components must be wrapped with Root')
}
return ctx
}
type MachineType = [
state: Machine.State<Machine.Definition.FromTypeParamter<typeof machine>>,
send: Machine.Send<Machine.Definition.FromTypeParamter<typeof machine>>
]
It'd be great if future iterations cover this usage as well. (Unless there are better ways to consume same machine by multiple components -- XState seems to have useInterpret
and useActor
.)
(Note, the internal Machine.Definition.FromTypeParamter
has a typo. It should be FromTypeParameter
)
Yeah that's problematic, #79 will solve this. And thanks for the feedback!
@devanshj I think this is a great idea, maybe even just for perf - the configuration will be created once instead of every time the component is rendered. It is also possible that the createMachine function will include some optimizations in the future? though probably not.
In the meantime, all I want is de-cluttering, I guess I could just extract the hook to its own function
First of all, thanks for this great little library built for React! The machine syntax is really nice and also the TypeScript support. 👍
My problem is that I want to first define the machine config and then use the config object for the hook. Using your sample machine, it would look like this:
The error that I get is:
I tries the following types without any success:
I also tried to get around the error by definition my own type. But it is incomplete and I don't want to redefine something that already exists somewhere else.
🤷🏻 How do I solve this problem in a clean way with defining my own types? They are already there. I just cannot figure how to apply them correctly.