Skip to content

Feature proposal: add util for state machines declaration #1065

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
Vladyslav-Murashchenko opened this issue May 18, 2021 · 12 comments
Closed
Labels
enhancement New feature or request

Comments

@Vladyslav-Murashchenko
Copy link

Vladyslav-Murashchenko commented May 18, 2021

Feature proposal: add util for state machines declaration

Plan:

  • Context: what do we have for now?
  • Idea: how can we make it better?
  • Solution: my thoughts about implementation
  • Final thoughts: possible improvements

Context: what do we have for now?

I like the idea of Treat Reducers as State Machines.
But, for my opinion, doing it like in the Detailed Example

const fetchUserReducer = (state, action) => {
  switch (state.status) {
    case IDLE_STATUS:
      return fetchIdleUserReducer(state, action);
    case LOADING_STATUS:
      return fetchLoadingUserReducer(state, action);
    case SUCCESS_STATUS:
      return fetchSuccessUserReducer(state, action);
    case FAILURE_STATUS:
      return fetchFailureUserReducer(state, action);
    default:
      // this should never be reached
      return state;
  }
}

Have some inconveniences:

  • verbose (need to create n + 1 reducer, where n status count)
  • status transition graph is implicit (to understand it we need to dive deep into reducers implementation)
  • uncommon (can be confusing for ones who never seen it)
  • not usable with RTK slices (at least in the common way)

The only example with state machines I found in RTK docs is this:

const usersSlice = createSlice({
  name: 'users',
  initialState: {
    loading: 'idle',
    users: [],
  },
  reducers: {
    usersLoading(state, action) {
      // Use a "state machine" approach for loading state instead of booleans
      if (state.loading === 'idle') {
        state.loading = 'pending'
      }
    },
    usersReceived(state, action) {
      if (state.loading === 'pending') {
        state.loading = 'idle'
        state.users = action.payload
      }
    },
  },
})

And I like this approach more because we don't need to create many reducers.
Adding condition into case reducer looks more common, but:

  • it is not really fun to wrap each case reducer code into if
  • we check the status before action but don't care about status after
  • status transition graph still implicit, hidden inside case reducers

When I talking about status transition graph, for xstate example fetch machine I mean something like:

idle -> [loading]
loading -> [success, failure]
failure -> [loading]
success -> []

Idea: how can we make it better?

It will be good to have a tool that helps:

  1. check current status without extra ifs and nesting
  2. allow to define state graph explicitly and check output status based on it
  3. play well with RTK slices

Solution: my thoughts about implementation

I think it is good idea if final API for connecting tool to slices will be higher-order case reducer
So we can rewrite this example from RTK doc, as:

const usersSlice = createSlice({
  name: "users",
  initialState: {
    status: "idle",
    users: [],
  },
  reducers: {
    usersLoading: whenIdle((state) => {
      state.status = "pending";
    }),
    usersReceived: whenPending((state, action) => {
      state.status = "idle";
      state.users = action.payload;
    }),
  },
});

For state graph declaration I used example from Grokking Algorithms book, if apply the same appreach to xstate example fetch machine, then I am getting:

const idle = "idle";
const loading = "loading";
const success = "success";
const failure = "failure";

const statusGraph = {
  [idle]: [loading],
  [loading]: [success, failure],
  [failure]: [loading],
  [success]: [],
};

After I combined higher-order case reducers with such state graph declaration. I received the next code on my current project:

const LOADING = "LOADING";
const LOADED = "LOADED";
const NEED_FETCH = "NEED_FETCH";

const whenStatus = createStatusMachine({
  [LOADING]: [LOADED],
  [LOADED]: [NEED_FETCH],
  [NEED_FETCH]: [LOADING],
});

const whenLoaded = whenStatus(LOADED);
const whenLoading = whenStatus(LOADING);
const whenNeedFetch = whenStatus(NEED_FETCH);

const plpSlice = createSlice({
  name: "plp",
  initialState: initialPLPState,
  reducers: {
    changePriceRange: whenLoaded((state, action) => {
      state.status = NEED_FETCH;
      // other state changes
    }),
    // other case reducers
});

// utils/createStatusMachine.js

const createStatusMachine = (statusGraph) => (statusToRunReducer) => {
  return (reducer) => (state, action) => {
    const { status } = state;

    if (
      typeof statusToRunReducer === "string" &&
      statusToRunReducer !== status
    ) {
      return state;
    }

    if (
      Array.isArray(statusToRunReducer) &&
      !statusToRunReducer.includes(status)
    ) {
      return state;
    }

    const result = reducer(state, action);

    if (process.env.NODE_ENV === NODE_ENV.DEV) {
      const posibleNextStatuses = statusGraph[status] ?? [];
      const newStatus = result === undefined ? state.status : result.status;

      if (!posibleNextStatuses.includes(newStatus)) {
        console.error(`
          Status received after reducer call is not correct
          reducer: 
          ${reducer}
          prevStatus: ${status}
          newStatus: ${newStatus}
          posibleNextStatuses: ${posibleNextStatuses.join(", ")}
        `);
      }
    }

    return result;
  };
};

For me, it seems like such an approach solves all the issues above.
We got:

  • declarative current status check without if
  • explicit status graph
  • console error if status received after reducer call is not correct

Final thoughts

I have just discovered the approach, but it looks very convenient to me.
If you also like it, or at least some of the ideas I will be glad to discuss, make improvements, create PR, contribute to the redux toolkit.

Can be improved:

  • maybe it better to implement it as createStateMachine without relay on hardcoded status prop
  • improve error message
  • rewrite with typescript
  • make output status check with typescript

Thanks for your attention, waiting for feedback

@phryneas
Copy link
Member

phryneas commented May 18, 2021

This was, at least partially, tried back in #366 - but it didn't seem worth it at that time.

I'm all for state machines, but I'm not sure if we do the world any favor if implementing that in RTK itself, adding an other state machine library to master to the mix.
From our side, that would take maintenance of quite a bit of extra code (and especially, types) where other tools that do the job better already exist.
But the main problem would honestly be documentation. This would add whole new paradigms on top of our existing documentation, probably multiple chapters with endless opportunity to extends.

So, it might maybe be a more sensible idea to provide a wrapper function that takes a finished state machine from another well-documented state machine library like XState (seeing how much stuff, including side effects, that library supports, maybe something smaller but equally fleshed out?) and creates case reducers (and thus, action creators) for each event of the machine.

So you could do something like

const machine = // ... whatever 

const slice = createSlice({
  // ...
  reducers: {
    myCustomCase(state, action){},
	...machineCases(machine)
  }
})

const { turnMachineLeft } = slice.actions

Note though that at the moment we are heavily working on RTK Query, so this is nothing we would do in the near future - unless we all agree on an approach and you start working on it yourself.

@Vladyslav-Murashchenko
Copy link
Author

@phryneas @markerikson I understand your point, it really doesn't have much sense to create one more state machines library. But this proposal is NOT a state machine library inside redux. Please read the next paragraphs, let me explain. Maybe you will find out how different is it from #366 and that it really can be useful.

For me, it looks like reducers are already state machines. With redux we already have:

  • state changes described as data
  • state transitions with pure function
  • side effects with middleware
  • redux-toolkit which helps to do it concise (thank you!)

But the most important thing which redux don't have to be a good state machine is explicit status transition graph.
And the simples way to express this graph is just:

const statusGraph = {
  [idle]: [loading],
  [loading]: [success, failure],
  [failure]: [loading],
  [success]: [],
};

We don't need any library to create such a graph, also we don't need a library for those things which redux already can do.
But I think we just need to have the ability to connect such a graph with redux. Because you have strongly recommended using state machines in redux doc, but don't provide any tool for it in redux-toolkit. So anyone will do it as he wants and we getting the same issue in codebases with I guess redux-toolkit trying to solve - consistency.

About XState:
XState - combine status graph together with actions, side effects, the context state management.
Last 3 - redux do better in my personal opinion.
But because of such a mix, the Xstate status graph is also not very convenient to read and XState fix this issue with a visualizer.
Connecting XState to redux breaks code consistency if we describing some state transitions with redux and other ones with XState which connected to redux.

Proposed approach example
While approach which I discovered allow splitting status graph out of context management, actions, side effect.
So it looks like you at first define a spec for your state machine and then implementing it with redux:

const whenStatus = createStatusMachine({
  [LOADING]: [LOADED],
  [LOADED]: [NEED_FETCH],
  [NEED_FETCH]: [LOADING],
});

const whenLoaded = whenStatus(LOADED);
const whenLoading = whenStatus(LOADING);
const whenNeedFetch = whenStatus(NEED_FETCH);

const plpSlice = createSlice({
  name: "plp",
  initialState: initialPLPState,
  reducers: {
    changePriceRange: whenLoaded((state, action) => {
      state.status = NEED_FETCH;
      // other state changes
    }),
    // other case reducers
});

I suppose doing the same with XState or any other library which implements half of redux itself and then connects it to redux will be much more complicated.

About documentation:
Personally, I understand state machines very superficially. In my opinion, the reason for the code above will be clear even for users of redux who have never seen state machines or graphs before. We can explain the reason for it without diving deep into state machines.
Also as I already mentioned before you have strongly recommended using state machines in redux doc, but don't provide any tool for it in redux-toolkit. And also don't any, at least small page about how to use state machines with redux-toolkit, because the "Detailed Example" in redux documentation looks not usable with redux-toolkit, especially with slices which you recommend to use

About issue #366:
This issue suggests adding a predicate prop in the same way as prepare works.
So it would be possible to avoid this:

startWork(state, action) {
  if (state.status === 'idle') {
    state.status = 'working'
    state.workItem = action.payload
  }
}

And write this instead:

startWork: {
  predicate: state => state.status === 'idle',
  reducer(state, action) {
    state.status = 'working'
    state.workItem = action.payload
  }
},

Sure it has no sense, as the first example is more concise.
But my proposal is:

const whenStatus = createStatusMachine({
  idle: ['working']
})

const whenIdle = whenStatus('idle'); // reusable

// ...some code
startWork: whenIdle((state, action) => {
  state.status = 'working'
  state.workItem = action.payload
})

// compare with
startWork(state, action) {
  if (state.status === 'idle') {
    state.status = 'working'
    state.workItem = action.payload
  }
}

So in my proposal, there are some benefits:

  • explicit and separate from logic status transition graph
  • fewer nesting, so easier to read
  • code reusing out of the box

I hope this time I expressed my view more clear. You can take a look into implementation and maybe you will find out that this thing is much simpler than you thought initially so it will not be so hard to support. Also, I don't think it would ever be extended with some complicated behavior or something like this, because all of those should be (redux + middleware) responsibility itself

@Vladyslav-Murashchenko
Copy link
Author

@davidkpiano I'd like to hear your thoughts about it. It would be super helpful thanks!

@Vladyslav-Murashchenko
Copy link
Author

Maybe it worth to rename util from createStatusMachine to defineStatusTransitions.
I think such a name will describe better what it does. And no needs to tell anyone about state machines.

@markerikson
Copy link
Collaborator

A few quick thoughts:

  • First, the upcoming RTK Query APIs should eliminate a large portion of the times when users would need to explicitly track state, because it already handles request loading state internally.
  • I feel like a createStatusMachine or defineStatusTransitions API would be far too limited and focused on a specific use case
  • But I also don't want to try to recreate existing state machine libs in RTK. I'd much rather look for ways to better integrate existing state machine libs into RTK's behavior rather than try and write something ourselves.
  • My general understanding is that state machine functions typically can be used as reducers already, although I don't have a specific example to point to right this second
  • Anything involving createSlice would have to work for the "builder callback" syntax in extraReducers, not just the reducers field

I'm open to discussions on the topic in general, but this really isn't a high priority for us right now.

@davidkpiano
Copy link

My general understanding is that state machine functions typically can be used as reducers already, although I don't have a specific example to point to right this second

Yes, and integration with libraries like XState are as simple as this:

import { createMachine } from 'xstate';

const someMachine = createMachine({ ... });

// this is a reducer
export const reducer = someMachine.transition;

@Vladyslav-Murashchenko
Copy link
Author

@markerikson Ok, I understand that my case maybe not typical, and not a priority for you.

But in my view sometimes such a case can happen:

  • Project use redux, redux-toolkit.
  • Using slices as recommended, with the "builder callback" syntax in extraReducers.
  • Slice becomes complicated and it is not clear what status do we have when we calling the action and what statues allowed to set from the action. So, we want to make our state machine more explicit

I solved this issue with the util described above. It just checks our current and output statuses based on transitions defined and provides a higher-order case reducer to wrap our case reducers into.

But you recommend rewrite it with XState or other state machine libraries instead, and then create a reducer out of it.

I created a sandbox that demonstrates the code of both approaches for equivalent reducer:
https://codesandbox.io/s/elegant-golick-zpf9f?file=/src/reducerWithMachine.js
https://codesandbox.io/s/elegant-golick-zpf9f?file=/src/reducerWithSlice.js

reducerWithMachine.js pros:

  • using the tool for state machine creating which was initially designed for it
  • already well documented and compatible with typescript

reducerWithMachine.js cons:

  • need to migrate code from slice to XState when slice become complicated
  • need to learn and introduce our team to so many new concepts
  • one more completely different way to create reducers in code
  • I am not satisfied with the current implementation and behavior of reducerFromMachine util, it is not obvious and probably more complicated than defineStatusTransitions
  • not very convenient to read because of big nesting (maybe just because I have never used it before)

reducerWithSlice.js pros:

  • no need to rewrite existing code when need to describe the statuses transitions
  • not so many new concepts to get things done
  • behavior defined separately from the implementation
  • compatible with everything that looks like a case reducer

reducerWithSlice.js cons:

  • one more vehicle which no one ever seen
  • don't have any documentation or types
  • not a state machine lib, just a way to check the status in reducer and define some specification, so surely cannot be so powerful as XState

Do you strongly recommend using reducerWithMachine.js in my case?
Are you going to add util for converting state machines from libs into a reducer at some point?

@asherccohen
Copy link

asherccohen commented May 22, 2021

In my opinion we should try to write an example that goes beyond data fetching, as we now do that trivially with RTK-query.

The real benefit of an explicit syntax for state machines is to be able to write "local machines" for our components.

We're clearly moving away from "big global state" (unless shared between many components) and are now working with a more "atomic" approach where components handle their logic separately from the store (hence xstate and so many other state management libraries).

But we don't want to loose on the syntax/ecosystem/docs that redux and the maintainers have provided us all these years.

I might sound heretic, but to me a util like the one in the proposal (or a wrapper for xstate) makes more sense in conjunction with useReducer.

We know for a fact createSlice works perfectly with useReducer (ok yes, we might loose something in regards to the devtools).

So yes to a util or a wrapper for explicit state machines with locked transitions", with a section in the docs describing/encouraging how this helps with local state, rather than global.

@Vladyslav-Murashchenko
Copy link
Author

@asherccohen I understand your point and generally agree.
Sure, it better not to use "big global state" when you don't need it. But "shared between many components" data quite a common case in my practice.

I don't think it has much sense to transform a machine from XState -> reducer just to use it with useReducer, as we can just use useMachine in this case. But using createSlice + defineStatusTransitions + useReducer have sense in my opinion.

I updated my old code with a game to use such an approach. A slice for game logic is here:
https://codesandbox.io/s/runtime-sun-1qhtw?file=/src/pages/Game/gameSlice.js

Here also the same code on GitHub:
https://github.com/VladislavMurashchenko/game-in-dots/blob/master/src/pages/Game/gameSlice.js
And deployed example:
http://game-in-dots.surge.sh/#/game

@markerikson markerikson added the enhancement New feature or request label Jun 7, 2021
@Vladyslav-Murashchenko
Copy link
Author

I also tried one more approach:
Using XState for finite part of state and RTK for infinite one.

Here is the code example:
https://codesandbox.io/s/elegant-golick-zpf9f?file=/src/reducerWithMachineAndSlice.js

I think this one approach is the worst one because:

  • It is impossible to use slices if define the machine before reducer because XState requires access to actions.
  • Handling of 1 action becomes splitter into 3 parts: guards, machine, and reducer. So it becomes complicated to read all the logic for one action.

Also, I think we will get pretty the same results with any other state machine library. I believe that defineStatusTransitions approach is the best in terms of compatibility with RTK because it doesn't require any pieces of knowledge about actions for defining transitions, so can be fully compatible with slices.

@Vladyslav-Murashchenko
Copy link
Author

Hey, hope you haven't forgotten about this proposal completely. And will take a look at it when you have time.
I have one more idea. We can simplify util to just check the current status.
Implementation with TypeScript you can find here:
https://codesandbox.io/s/elegant-golick-zpf9f?file=/src/utils/whenStatus.ts
Tests:
https://codesandbox.io/s/elegant-golick-zpf9f?file=/src/utils/whenStatus.test.ts
Examples:
https://codesandbox.io/s/runtime-sun-1qhtw?file=/src/pages/Game/gameSlice.js
https://codesandbox.io/s/elegant-golick-zpf9f?file=/src/reducerWithWhenStatus.ts

If someone like me wants to make status transitions explicitly, he can use objects like this to specify transitions:

export const from = {
  preparing: {
    to: {
      playing,
    },
  },
  playing: {
    to: {
      preparing,
      finished,
    },
  },
  finished: {
    to: {
      preparing,
    },
  },
};

And make transitions with something like:

state.status = from.playing.to.finished;

@markerikson
Copy link
Collaborator

I'll be honest, I still don't understand exactly what's being proposed here, and what I'm seeing out of these examples does not look like something we're going to add to RTK.

@davidkpiano has already shown that you can trivially use a state machine function as a reducer. I think it would be useful to have more examples of integration with XState, like https://github.com/mattpocock/redux-xstate-poc (and even using https://xstate.js.org/docs/packages/xstate-fsm/ as a lighter-weight implementation), than trying to build something ourselves.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

5 participants