cassiozen / usestatemachine Goto Github PK
View Code? Open in Web Editor NEWThe <1 kb state machine hook for React
License: MIT License
The <1 kb state machine hook for React
License: MIT License
type send func
I’m a huge XState fan and this library obviously took a lot of inspiration from it. XState is still the golden standard for state machines in JavaScript, but this library has a more niche use case:
It's React specific: it follow react idiomatic patterns (like naming the transition "effect" and following the same useEffect cleanup pattern). Being React-specific also means it can use React’s built-in hooks, useReducer and useEffect do most of the heavy lifting, and as a good consequence this lib is small.
It contains a subset of features from XState: I only added to this library functionality that I used over an over again in state machines. I plan to keep it this way (maybe adding simple aktors, will see).
It is a little less strict: You can call send from effects, effects can be inlined in the state machine config. I think this makes it more "practical" for daily use at the cost of strict correctness.
Finally, it has a heavy focus on type inference - it's nice when a library can provide autocompletion and type checking without requiring you to manually provide typing definitions.
If the user is passing send
down to child components, and these child components use React.memo
or PureComponent
, the send
method will force a render anyways.
Not a huge issue, but it should be dealt with.
TSDoc can add documentation right inside the ide.
@cassiozen How and where would you want me to write that document covering typescript features? Like maybe in a "Features" section? Or like in a changelog? (that would be lame haha)
And if I'm going to use shiki-twoslash we'd have to host it somewhere. So should we make a documentation website? Or if all this is on low priority I can simply add a wiki page mentioning typescript features with screenshots?
I'm not sure how to go about this haha
I used useStateMachine in a Nextjs project. It is working fine in the development environment, and it run fine when I locally run Next's build files generated after running build command. However, when I deploy it on a server, the server complains nothing is returned by useStateMachine. Can you suggest any way to resolve this?
Consider this example -
const Something = () => {
let machine = useStateMachine({
initial: "subscribed",
states: {
subscribed: {
effect: () => {
let unsubscribe = spawnSomeService();
return () => unsubscribe()
}
}
}
})
}
Does anything look fishy here? No right? Except it has a memory leak. The effect cleanup never runs, even after Something
is unmounted. So if it's remounted 10 times through out the lifetime of the page, then 10 services would be running instead of 1.
To prevent the memory leak one has to do something like this -
const Something = () => {
let machine = useStateMachine({
initial: "subscribed",
states: {
subscribed: {
effect: () => {
let unsubscribe = spawnSomeService();
return () => unsubscribe()
},
on: { UNSUBSCRIBE: "unsubscribed" }
},
unsubscribed: {}
}
})
useEffect(() => {
return () => machine.send("UNSUBSCRIBE")
}, [])
}
This is not intuitive at all. Not to mention the fact that the effects don't cleanup on unmount is very misleading. So people probably will be having memory leaks in their apps unknowingly.
You see other libraries don't call it "effect" they call them "entry" and "exit". Latter does not have to guarantee that each node that has been entered will always be exited but former has to guarantee that every effect's cleanup will run because the terminology and effect-looking api it chose (which is a good choice as it makes things like this more apparent and cleanups easy to write because the unsubscribers will be in the closure)
So to solve this I propose we have a $$stop
event (and rename $$initial
to $$start
) which would be sent when the component gets unmounted and will basically exit all nodes upto the root (so it'll only show up in cleanup's event parameter and not effect's).
And having a stop event makes sense if you think of spawning the machine one big effect, if it has a start event and does some effects, it should also have a stop event that disposes stuff spawned by the effects.
Trivia:
I was writing the hierarchical implementation where I realized root is the only node which would never exit no matter what, and thought maybe that's why we don't have effect on root as it won't cleanup. But then I realized that's true for all nodes that are active before unmounting.
It's funny I didn't plan to run the root effect, the recursive abstraction demanded me to do it. That's what I like about fp that it demands some consistency, some symmetry, some pattern and if there's something fishy and you don't have these things, then you'd catch it right off the bat as you'd have to go out of your way to do something asymmetric.
While updating the timer example, I noticed this bug:
The weird part is that nextEvents
type looks correct "START"[] | "PAUSE"[] | ("START" | "RESET")[] | undefined
I've got useStateMachine
in a parent component and want to send it down as props. How can I get the type for state
and send
?
Calls to update and send may happen outside of a user interaction, React won’t batch the state updates.
on
should be optional (final states, for one, won't have any transitions), but currently, if we create a config with any state missing the on
key, the Event
type turns to never
.
This can be seen in the data fetching example: Try invoking send
outside of the machine and you will get never
as type.
Nested states are beneficial for a myriad of reasons, including:
This is what a nested state machine configuration would look like:
// Traffic Lights
useStateMachine()({
initial: "green",
states: {
green: {
on: {
TIMER: "yellow",
},
},
yellow: {
on: {
TIMER: "red",
},
},
red: {
on: {
TIMER: "green",
},
// Nested state: Pedestrian traffic light
initial: "walk",
states: {
walk: {
on: {
PED_COUNTDOWN: "wait",
},
},
wait: {
on: {
PED_COUNTDOWN: "stop",
},
},
stop: {},
},
},
},
});
The returned machine state would still have a string representation for the state value. Nested stated will be represented with dot syntax. For the above example, possible states are:
green
yellow
red.walk
red.wait
red.stop
For every transition, the state machine should traverse the config three (depth-first) and find the shortest path for the transition.
Effects should be invoked on parent state only once: transitioning between child states should not re-fire parent effects.
Add a debug mode, where all operations are logged:
Should use process.env.NODE_ENV === 'dev'
to let bundlers strip the messages.
Event is not optional now, and starts with $$initial
Right now events (under the "on" key) can only be declared within a state. top-level events can be helpful to avoid redundancy / keep the state machine terse.
It looks like there is currently no way to provide external information when transitioning states, I read the source code for send
, and the examples (such as fetch) seem to internalise the external effect so that the data is available within effect
. Apologies if I did a poor job of looking.
My use case is moving data from a subscription callback into the state machine. I need to manage an external lifecycle (add/remove subscriber), and data is supplied to me via callback to the subscriber. Firebase's realtime database is a concrete example:
const [state, send] = useStateMachine({firebaseValue: …})({
initial: 'loading',
states: {
loading: {
on: {
SUCCESS: 'loaded',
FAILURE: 'error',
},
effect(_, update, /* ??? */) {
update(context => /* External value would be placed into the context here. */)
},
},
// …
},
})
React.useEffect(() => {
// A hypothetical `send` API with parameters.
const success = value => send('SUCCESS', value)
const failure = error => send('FAILURE', error)
const ref = firebase.database().ref('some/path')
// Manage the subscriber lifecycle.
ref.on('value', success, failure)
return () => {
ref.off('value', success)
}
)
I realise it would be possible to achieve this with a ref, but I would prefer not having one foot in the door in terms of state and context.
The "send" function argument type should be an union of transition names. It works properly in the send method returned by useStateMachine - but the send method passed to effect currently broadens to "string".
(For my reference so that I don't forget)
Provide a hack-free safe degraded version of types. Because the currect depend on some likely typescript non-guarantees that might break, So if some users would like to trade safety over precise types and developer experience they can use that version. And we can also be less worried of making workarounds because we have already warned "Hey this might break" :P
Two ways of going about this -
@cassionzen/usestatemachine/safe
(temporary name)declare module "@cassionzen/usestatemachine" {
interface TypeOptions {
safe: true
}
}
InferNarrowestObject
which is almost same as this and Definition.FromTypeParameter
(TODO: document what's happening here)For milestone v1.0.0
Support guard's that are actually typed as type guards. Meaning this should work -
const eventHasFoo = (state: { event: { foo?: string | undefined } }): state is { event: { foo: string } } =>
typeof state.event.foo !== "undefined"
// I always wanted to publish a library provides authoring predicates that infer guards, which I might :P
// Meaning with that library the above can be replaced by something like
// let eventHasFoo = P.doesHave("event.foo", P.isNotUndefined)
// and if they write it in the machine itself (which they should) they'd also get completions and errors via contextual inferrence.
useStateMachine({
schema: { events: { X: t<{ foo?: string | undefined }> } },
initial: "a",
states: {
a: { on: { X: { target: "b", gaurd: eventHasFoo } },
b: {
effect: ({ event }) => {
let x: string = event.foo
// without `gaurd: eventHasFoo` foo would have been `string | undefined`
}
}
}
})
It's great the signature is ({ context, event }) => ...
instead of (context, event) => ...
like xstate, because latter is principally incorrect and consequently has some cons
For milestone v1.1.0
Provide (probably opt-in) linting
noInitialToDeadStateNodes
// needs to be written once per (tsconfig) project
declare module "@cassionzen/usestatemachine" {
interface TypeOptions {
noInitialToDeadStateNodes: true
}
}
useStateMachine({
initial: "b", // Error(noInitialToDeadStateNodes): "b" is a dead state node
states: {
a: { on: { X: "b" } },
b: {}
}
})
noNonExhaustiveEventsSchema
-
useStateMachine({
schema: {
events: { // Error(noNonExhaustiveEventsSchema): `$$exhaustive` not set to `true`
X: t<{ foo: string }>()
}
}
})
noSchemalessDefintion
useStateMachine({ // Error(noSchemalessDefintion): `schema` is not set
initial: "a",
states: { a: {} }
})
For milestone v1.2.0
more?
Use case: forms or subscriptions
The user wants to update the context with data coming from outside, like a form or a subscription.
We don't want to expose the updater function to the outside world, so having send accept a payload and making it available to the updater function would be a solution.
Looks like TSDX is not being actively maintained anymore (jaredpalmer/tsdx#1065) - we need to migrate away.
Right now the state value is always a string, but since we plan to introduce hierarchical fsms in the future (#21), state values might be represented differently.
Adding a 'matches' function on the machine state prototype (like XState) will give users a consistent and easy way to derive ui from the machine state.
Hey @devanshj, in this example, TypeScript is erroring because of the "$$initial" event:
const [machine, send] = useStateMachine({
schema: {
context: t<{time: number}>(),
events: {
STOP: t<{resetTime: number}>()
}
},
context: {time: 0},
initial: 'idle',
states: {
idle: {
on: {
START: 'running',
},
effect({setContext, event}) {
setContext(() => ({ time: event?.resetTime }));
// ^- Property 'resetTime' does not exist on type '{ type: "$$initial"; }'
},
},
running: {
on: {
STOP: 'idle',
}
}
},
});
send({ type: 'STOP', resetTime: 10 });
I think it might be confusing for the user to understand what's going on here. And since so far we're not really using the initial event to trigger anything, I'm thinking of reverting back to start undefined
. Any objections?
Currently using %o. Change for %s or %j
Hi,
First great work! Can't wait to start use this on my projects from now on.
I just want to suggest, if you find suitable, that in the wiki page in comparison with XState, that if for some reason people are looking for a minimal implementation of XState for another use-cases that are not React (lambda is one I could think on the top of mind), they can use @xstate/fsm.
Thanks and again congrats on the great work!
Right now, the context updater function behaves almost like a reducer: it gives you the current context and you have to immutably return the next context.
It would be nice if it merging syntax like the old React's component-based setState.
update
signature inside effect.@RunDevelopment, @dani-mp, @RichieAHB, @icyJoseph, and anyone interested.
One issue we currently have within effects is that even though send
and update
are different functions, they're frequently used together (update the context, then trigger a transition). E.g.:
effect(send, update) {
const fetchData = async () => {
let response: Response;
try {
data = await someApi();
update(context => ({ ...context, data: data }));
send('SUCCESS');
} catch (error) {
update(context => ({ ...context, error: error.message }));
send('FAILURE');
}
};
fetchData();
}
The proposal is to pass a new assign
parameter to effect
. It's a function that takes an Object as argument with the type:
{
update?: (context: Context) => Context;
event?: EventString | EventObject<EventString>;
}
The above fetch example would look like this:
effect(assign) {
const fetchData = async () => {
let response: Response;
try {
data = await someApi();
assign({update: context => ({ ...context, data: data }), event: 'SUCCESS'})
} catch (error) {
assign({update: context => ({ ...context, error: error.message }), event: 'FAILURE'})
}
};
fetchData();
}
The proposal is to keep passing both send
and update
(as well as event
, in the future) to effect
, BUT with one change: update
will also return send
, so you can couple updating the context and sending event on a single line:
update(updaterFn)('SUCCESS');
Granted, it might look a little weird at first, but it has a few advantages:
send
or just update
(keep a simple API and backward compatible)We could remove the update
method from effect
and overload send
to also update the context:
send(event: string)
send(event: string, updater: fn)
send(updater:fn)
I see a few disadvantages here:
send
method inside effect
would be different from the send
returned when calling useStateMachine. We might need to come up with different nomenclature or incur the risk of making the API confusing.Thoughts? Ideas? Comments?
Return a function state.mayTransition
that allows a user to ask "if I sent this event, would it pass the guards on transitions?"
Usage:
const [state, send] = useStateMachine()({
initial: "inactive",
states: {
inactive: {
on: {
ENABLE: "active",
},
},
active: {
on: {
DISABLE: {
target: "inactive",
guard: () => false,
},
},
},
},
});
state.mayTransition("ENABLE"); // true
send("ENABLE");
state.mayTransition("DISABLE"); // false
Allow selectively rendering or disabling UI input - such as navigation or submission buttons - based on whether the intended event would pass all guards for the transition. (This would be lazily executed, in case someone's machine has side effects in guard()
.)
From quick testing, this would be a very small code increase.
As far as I can tell, xstate doesn't support this, but I don't know why - or I don't know how to search for it. It's normal for Ruby state machines, though.
mayTransition
might not be a great name! I'm not sure what makes sense... maySendEvent
? wouldEventBeAccepted
? Naming is hard.
Effects provided two functions as parameters: send
& update
.
In many cases (as illustrated by the async example), send
& update
will be used together.
Maybe instead of two separate functions, we should have an overloaded 'send':
send(event: string)
send(event: string, updater: fn)
send(updater:fn)
I was wondering if you want to give some name to your library other than "useStateMachine". Mainly because saying "useStateMachine" doesn't ring bells that I'm talking about your library, it could be a generic hook or even other libraries out there. So right now I mention it like "Cassio's useStateMachine".
It doesn't have to be fancy something, "CState" (like XState but C for Cassio) also works haha, just like how "M" in "Mobx" is for Michel. Or if you have something original out of the world in mind that would be even better!
In case your mother tongue is not English, you can name the library whatever "state" is called in your mother tongue. That's what I do sometimes xD
Often pieces of state will be needed for decisions in the effect methods. Would you recommend keeping these instead in an external useState, or is there some way to pass these in via events (without changing state).
for instance, in xstate you can do something like this:
{
states: {
start: {
on: {
readCountChange: {
actions: [assign((context, event)=>{ return { readsExist: event.readCount > 0}})]
}
}
...
}
}
And then use that readsExist later in a condition to decide if some other event should be allowed to make a state change.
Problem:
Let's say you're working with a state machine with nodes a and b and context as A | B
, now you know that in node a the context is always of type A
and in node b the context is of type B
. But the context receive in the effect will be of type A | B
for both nodes and the user would have to make assertions.
xstate's solution:
The way xstate solves this is by letting the user define the relation between state and context via the "TTypestate" type parameter and "typestate" being an actual thing.
txstate's solution:
xstate has an advantage that the context is immutable, the only way to mutate it is via assign
. So all mutations can be found by looking at the type of the machine and hence the typestates can be derived and the user need not write them manually. Explained in detail here
useStateMachine's solution:
Thing is right now there isn't a way to solve this because the context is fully mutable, not only it is mutable but it can be mutated many times inside an effect (you can spin up a setInterval
which changes the shape of context each time)
I've opened this issue to sort of discuss if we can improve on this. My first attempt is to make effect an generator, then the user can basically emit mutations and the they will be extracted to the type as typescript can infer generators too (hover over effect) and it seems there is a lot of prior art in using generators as effects (redux-saga, fx-ts, et al). But the problem is right now effect is even capable of "reducing" the context instead of just setting it. Which basically comes in the way.
So my first question is if setContext
does not take a reducer do you think there will be things user won't be able to do? Like the effect only receives a context which would be on the entry then there's no way to determine the current context, you think that'd make a big difference?
Also more important question haha - you think the problem that I point out is big enough that we even think about solving it? If not then we can close this (to mark the stance) and still keep discussing if both of us are still interested.
Hey! 👋🏽
I've been exploring the library and I was wondering how guards fit when talking about "conditional transitions" - I'm not sure that's the proper name for the scenario below.
Consider the following scenario:
A
or B
My question is about what one should do in other to transition to A
or B
.
Currently, the guard
feature works when transitioning to a specific state - it can either proceed with the transition to that state or stay in the current state, depending on the boolean value returned by the guard
function.
From what I understood, the only way to perform a "conditional transition" is doing the check inside the effect and calling send
accordingly.
initial: {
on: {
GO_TO_A: 'A',
GO_TO_B: 'B',
},
effect(send) {
send(condition ? 'GO_TO_A' : 'GO_TO_B');
},
},
I was wondering if it fits the state machine model (and this library's goal of course) to have guarded transitions where one could transition to the next state based on the guard result. Something like, "if guard
returns true
, go to A
, otherwise go to B
".
// the names are just for clarification purpose
initial: {
on: {
NEXT_STATE: {
guard: (...args) => boolean,
targetIfTruthy: 'A',
targetIfFalsy: 'B',
},
},
effect(send) {
send('NEXT_STATE');
},
},
xstate
has guarded transitions, but the signature is an array and I think one can put as many {target, cond}
items as needed.
My question/proposal relates to conditionally transitioning between two states but I linked their approach in case you see the value and decide to implement the feature similarly - with many possible next states.
If something above isn't clear or if there's a way to accomplish what's proposed here, let me know.
The fact that useMachine
“creates” the machine means that we can only create a machine inside an component. This problematic because...
createDefinition
solves 1 but doesn't solve 2 so we need something like xstate ie let m = createMachine(d);
then later in component useMachine(m)
. We could add an overload to do useMachine(d)
which saves users from writing useMachine(createMachine(d))
Hey! 👋🏽
I'm trying out this library and I'm wondering about accessing the context
within an effect.
My question regards something like this:
context
and transits to another stateimport useStateMachine from '@cassiozen/usestatemachine';
const [state, send] = useStateMachine({ count: 0 })({
initial: 'first',
states: {
first: {
on: { second: 'second' },
effect(send, update) {
update(context => ({ count: context.count + 1 }));
send('second');
},
},
second: {
effect(send, update) { // <- context isn't accessible here
console.log(state.context.count); // is this the only/correct way to access it?
},
},
},
});
Being recently new to state machines, I'm wondering if this is by design and/or if context
could be a parameter to the effect
function.
If could be added, I'd love to contribute to it!
I have used xstate
a bit and there I can access context
within actions and services.
useEffectReducer
allows access to context within effects.
useStateMachine is a curried function (Yummm tasty!) because TypeScript doesn't yet support partial generics type inference. This workaround allows TypeScript developers to provide a custom type for the context while still having TypeScript infer all the types used in the configuration (Like the state & transitions names, etc...).
We no longer have this limitation with #36. We can have something like the following for context...
useStateMachine({
initial: "idle",
context: { foo: 1 } // I prefer "initialContext" but whatever xD
...
})
And something like this (what xstate v5 does), for events and context...
useStateMachine({
schema: {
context: createSchema<{ foo: number, bar?: number }>(),
event: createSchema<
| { type: "X", foo: number }
| { type: "Y", bar: number }
>(),
// I prefer "event" instead of "events" because the type is for event and not "events"
// (unions need to be named singular even though they seem plural)
// though "events" works too if that's more friendly
},
initial: "idle",
context: { foo: 1 }
...
})
trying to use with https://react-hook-form.com/ and make a multi page wizard form.
A guard method seemed ideal to do validation between pages, however the validation function I need to call is async.
Is there any known way to have an async guard?
example
const [state, send] = useStateMachine({ initial: 'name', states: { name: { on: { BACK: { }, NEXT: { target: 'location', guard: async ({ context, event }) => { //ERROR return await trigger(["field1", "field2]); }, } } }, etc
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.
While typing I was confused why and in what cases the event would be undefined. Only when I was rewriting I realized that the initial event is undefined, which is not that explicit at it's intent.
Libraries often have their initial event, like xstate has "xstate.init"
, redux has "@@INIT"
afaik, storeon also has "@init"
. It's better to have event: { type: "FOO" } | { type: $$initial }
rather than have event?: { type: "FOO" }
. So I'd suggest there should be an explicit initial event, perhaps a symbol would be good.
Hey @devanshj, when using types events, I'm getting a confusing error message. Here's a sample case:
1 - I have a types "UPDATE" event that requires a string value
2 - When trying send({ type: "UPDATE" })
it gives me an error (right, it is missing the value
)
3 - Problem is, the error says Type '"UPDATE"' is not assignable to type '"some other type"'
:
Here is an example (the examples/form
with this added send on line 57:
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.