I'm looking into the new Typestates functionality in 4.7 and I'm a bit confused about the distinction between createMachine and Machine. It looks like you have to use createMachine to use Typestates as there is no generic for them with Machine. But, createMachine is missing the generic for the state schema which is something I find helpful.
It doesn't look like this is an oversight, does that mean representing your schema as a type is no longer considered good practice? Is there an alternative that makes it unnecessary to use with createMachine?
Im curious - what kind of advantage do you see in schema type? For me it seems like a huge (annoying) repetition
In V5 we hope to get rid of StateSchema and instead infer the schema from the actual configuration object passed in.
However for V4.x, it might be helpful to add in support for a Typestate generic in the Machine factory function.
Ah, that sounds great. Though actually thinking about it now I'm now sure what value the schema type provides. I do find it handy to have a simple overview of all the states the machine can be in but I was thinking it provided autocomplete in the targets which I have realized is incorrect.
@Andarist I love state schemas as it is the primary way for me to make sense of a machine. When actions, services, guards, and everything is already defined, it can be hard to see all the different states in the implementation.
As I haven't really used TypeStates yet, how would I represent parallel nodes or leave nodes that use the same state node name?
For a state machines as in the docs, it's pretty straightforward:
type UserState =
| {
value: 'idle';
context: UserContext & {
user: undefined;
error: undefined;
};
}
| {
value: 'loading';
context: UserContext;
}
| {
value: 'success';
context: UserContext & { user: User; error: undefined };
}
| {
value: 'failure';
context: UserContext & { user: undefined; error: string };
};
But what if I have a context and (non-parallel) structure that looks like this:
interface SearchContext {
userResults: IUser[] | undefined;
fullTextResults: IOther[] | undefined;
}
interface SearchStateSchema {
states: {
users: {
states: {
idle: {};
searching: {};
done: {};
};
};
fullText: {
states: {
idle: {};
searching: {};
done: {};
};
};
};
};
Is there already a way to define varying typestates for fullText.done and userSearch.done?
I'm not sure if the following way to express the Typestate works at the moment:
type SearchState =
| {
value: 'fullText.searching' | 'users.searching';
context: SearchContext & { userResults: undefined; fullTextResults: undefined }
| {
value: 'fullText.done';
context: SearchContext & { userResults: undefined; fullTextResults: IOther[] }
}
| {
value: 'users.done';
context: SearchContext & {userResults: IUser[]; fullTextResults: undefined };
};
I think this or any other way to express typestates is alright but if you allow me to throw in an alternative API proposal for typestates, I'd much rather express the context within the StateSchema. This would have the benefit of not relying on a string-based API and could eventually allow a typed state.matches() API for which we'd need to know the structure anyhow.
// state schema with typestates?
interface SearchStateSchema {
states: {
users: {
// force the fullTextResults to be undefined when the users state is entered
context: SearchContext & { fullTextResults: undefined };
states: {
idle: {};
searching: {
context: SearchContext & { userResults: undefined };
};
done: {
context: SearchContext & { userResults: IUser[] };
};
};
};
fullText: {
// force the userResults to be undefined when the fullText state is entered
context: SearchContext & { userResults: undefined };
states: {
idle: {};
searching: {
context: SearchContext & { fullTextResults: undefined };
};
done: {
context: SearchContext & { fullTextResults: IOther[] };
};
};
};
};
};
Up top, David was saying
we hope [...] to infer the schema from the actual configuration object passed in.
Maybe we don't need a state schema, not even for a typed state.matches() API. I personally like my proposed typestate within state schema API most as I wouldn't have to use strings and still have an easy way to get a quick overview of all the states of a machine.
I also oftentimes felt that writing the state schema upfront made me come up with a better overall state architecture as opposed to directly writing out the implementation.
When refactoring, I always found it to be far less intimidating to move state nodes around inside the state schema and keep the implementation until one has found a nice structure.
Happy to hear your thoughts on this 馃槉
Thanks for your thoughts, @CodingDive. This is still being explored; ideally, as much as possible should be inferred from the passed in config object, but I see the value that some sort of state schema would provide. It would be better if that state schema could be _generated_.
q: would in-editor schema visualizer or something like "copy schema" command in the IDE solve your concerns @CodingDive ?
@Andarist yes, good visualization or autogeneration would definitely be a very good solution to my problem.
The only problems I see then are
createMachine. Conceptually, I like to define interfaces and types first and then go towards the implementation. Therefore, this will require quite a mindset shift.My proposed API fixes both of those problems at the cost of keeping the state schema and being
a huge (annoying) repetition
with which I can definitely emphasis too.
Maybe there's yet a better solution we're missing?
@CodingDive @Andarist I have a PR out with a proposed simplification of Typestates. I believe it should simplify the case of nested types posted by @CodingDive as well:
/** Note that each typestate only needs to worry about its own relevant context fields */
type SearchState = CombineTypestates<
| { value: 'users'; context: { userResults?: IUser[] } }
| { value: { users: 'idle' } | { users: 'searching' }; context: {} }
| { value: { users: 'done' }; context: { userResults: IUser[] } }
| { value: 'fullText'; context: { fullTextResults?: IOther[] } }
| { value: { fullText: 'idle' } | { fullText: 'searching' }; context: {} }
| { value: { fullText: 'done' }; context: { fullTextResults: IOther[] } }
>;
/**
* Equivalent to:
*
* type SearchContext = {
* userResults?: IUser[];
* fullTextResults?: IOther[];
* }
*/
type SearchContext = TypestateContext<SearchState>;
Thanks to TypestateContext, it should also be possible to further simplify this with another ComposeTypestates utility like:
type GenericSearchState<K extends string, T> = CombineTypestates<
| { value: 'idle' | 'searching'; context: {} }
| { value: 'done'; context: Record<K, T[]> }
>;
type SearchState = ComposeTypestates<
| { value: 'users'; state: GenericSearchState<'userResults', IUser> }
| { value: 'fullText'; state: GenericSearchState<'fullTextResults', IOther> }
}>;
Which would be equivalent to:
type SearchState =
| {
value: 'users';
context: { userResults?: IUser[]; fullTextResults?: undefined };
}
| {
value: { users: 'idle' } | { users: 'searching' };
context: { userResults?: undefined; fullTextResults?: undefined };
}
| {
value: { users: 'done' };
context: { userResults: IUser[]; fullTextResults?: undefined };
}
| {
value: 'fullText';
context: { userResults?: undefined; fullTextResults?: IOther[] };
}
| {
value: { fullText: 'idle' } | { fullText: 'searching' };
context: { userResults?: undefined; fullTextResults?: undefined };
}
| {
value: { fullText: 'done' };
context: { userResults?: undefined; fullTextResults: IOther[] };
};
Also, with TypeScript 4.1 it will be possible to use string types for nested state values e.g. 'fullText.done' instead of { fullText: 'done' }: https://devblogs.microsoft.com/typescript/announcing-typescript-4-1-beta/#template-literal-types
Most helpful comment
@CodingDive @Andarist I have a PR out with a proposed simplification of Typestates. I believe it should simplify the case of nested types posted by @CodingDive as well:
Thanks to
TypestateContext, it should also be possible to further simplify this with anotherComposeTypestatesutility like:Which would be equivalent to: