cassiozen / useStateMachine

The <1 kb state machine hook for React
MIT License
2.38k stars 47 forks source link

How to use external machine config with correct type (using TypeScript)? #78

Closed fabiradi closed 2 years ago

fabiradi commented 3 years ago

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:

const machine = {
  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)

The error that I get is:

Argument of type '{ initial: string; states: { inactive: { on: { TOGGLE: string; }; }; active: { on: { TOGGLE: string; }; effect(): () => void; }; }; }' is not assignable to parameter of type 'InferNarrowestObject<Definition<{ initial: string; states: { inactive: { on: { TOGGLE: string; }; }; active: { on: { TOGGLE: string; }; effect(): () => void; }; }; }, { inactive: { on: { TOGGLE: string; }; }; active: { on: { TOGGLE: string; }; effect(): () => void; }; }, undefined, false>>'.
  Types of property 'initial' are incompatible.
    Type 'string' is not assignable to type '"inactive" | "active"'.ts(2345)

I tries the following types without any success:

const machine1: Machine.Definition.Impl = { /* */ }
const machine2: Machine.Definition<unknown, { inactive: 'inactive'; active: 'active' }> = { /* */ }

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.

  interface IMachine {
    initial: string
    states: {
      [key: string]: {
        on: { [key: string]: string | { target: string /*guard?: any*/ } }
        effect?: () => void // works, but misses the arguments...
      }
    }  
  }

🤷🏻 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.

devanshj commented 3 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:

  1. Errors would be less readable, like here, scroll all the way down in the tooltip to see "foo" is not assignable to "inactive" | "active"
  2. You get no completions/suggestions when you write your defintion
  3. You'll have to type the effect & guard parameters yourself, they can't be inferred because we leverage contextual inference to compute them precisely (I'm still to document all this). For example:

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!

https://github.com/cassiozen/useStateMachine/blob/6b0ac5306ebc26d310933c38d097f88ae7ab192b/src/types.ts#L5

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.

fabiradi commented 2 years ago

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)

Nest steps?

How can I use the great machine syntax? I really like the way, machines can be defined!

Why all that?

const [state, send, definition] = useStateMachine(machine)

graph TD
    start( ) --> inactive
    inactive --TOGGLE--> active
    active --TOGGLE--> inactive
statemachine-mermaid
devanshj commented 2 years ago

Thanks for reporting you're providing really useful feedback!

It's a known typescript limitation (I forgot about this) that effects 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.

fabiradi commented 2 years ago

🚀 Excellent! This solved it!

I needed to disable two eslint rules, but that is just a formal issue.

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.

devanshj commented 2 years ago

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!

smhmd commented 2 years ago

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)

devanshj commented 2 years ago

Yeah that's problematic, #79 will solve this. And thanks for the feedback!

KutnerUri commented 1 year ago

@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