cassiozen / useStateMachine

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

Experiment: Extract context mutations to type level. #54

Open devanshj opened 3 years ago

devanshj commented 3 years ago

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.

cassiozen commented 3 years ago

That's a very good question. The target audience for useStateMachine is not a hard-core State Machine user, but someone who wants to start using state machines in some parts of their app, as a consequence useStateMachine is designed to be less strict and get out of the way. So the user can, for example, call setContext multiple times in the effect - we even have an example that does that. I think that keeping this flexibility is more important than tying a relation between context and state.

That being said, I would love to have that. It's tremendously important, and if we can come up with a way to implement that that doesn't come at a cost of this current flexibility (or even if it has a cost, but one that doesn't completely shave, for example, the ability to do a timer that changes the context inside the effect), it would certainly be a great addition.

devanshj commented 3 years ago

Great! It doesn't shave that ability, the change I meant was...

- setContext: (updator: (context: Context) => Context) => void
+ setContext: (context: Context) => void

Would you say this change hampers the current flexibility?

I see only one example affected by this, and it can be refactored like this...

- effect({ setContext }) {
+ effect({ context, setContext }) { 
-  setContext(context => ({ ...context, retryCount: context.retryCount + 1 })).send('RETRY');
+  setContext({ ...context, retryCount: context.retryCount + 1 }).send('RETRY');
}
cassiozen commented 3 years ago

What about the timer example? Currently it looks like this:

running: {
        on: {
          PAUSE: 'paused',
        },
        effect({ setContext }) {
          const interval = setInterval(() => {
            setContext(context => ({ time: context.time + 1 }));
          }, 100);
          return () => clearInterval(interval);
        },
      },

Won't context become stale with this change?

devanshj commented 3 years ago

Won't context become stale with this change?

It certainly would, though the user can refactor it like this...

effect: function* ({ context }) {
  let time = context.time;
  while (true) {
    await new Promise(r => setTimeout(r, 100));
    time++;
    yield { ...context, time }
  }
}

Of course this assumes that only one effect is mutating the context at once. If multiple effects mutate the context parallely but different properties then we might even provide a set function and then the user would do yield set({ time }) instead which would "update" the context instead of setting it (though I call it set because for the user they are setting time. I think redux-saga has this they call it put). And set being something like <T>(value: T) => { type: "SET", value: T }. We could even directly provide a function like increment so they'd just yield set({ time: increment() }) at intervals. The only limitation is the user can't access the current context directly and there are many ways to get around it.

There are other gaps to fill like how would on set up a clean up maybe we can have something like yield cleanup(() => {}).

And the users don't have to write while (true) etc they can also leverage things like IxJS and it'd look something like...

effect: function* ({ context }) {
  for await (let i of interval(100)) {
    yield set({ time: context.time + i })
  }
}

Or if someone wants to write it in functional style then...

effect: ({ context }) =>
  interval(100)
  .pipe(map(i => set({ time: context.time + i })))

Though this is all for power users, for basic stuff like waiting for a promise just a simple await would do.