Plan:
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:
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:
ifWhen I talking about status transition graph, for xstate example fetch machine I mean something like:
idle -> [loading]
loading -> [success, failure]
failure -> [loading]
success -> []
It will be good to have a tool that helps:
ifs and nestingI 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:
ifI 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:
createStateMachine without relay on hardcoded status propThanks for your attention, waiting for feedback
This was, at least partially, tried back in https://github.com/reduxjs/redux-toolkit/pull/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.
@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:
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:
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
@davidkpiano I'd like to hear your thoughts about it. It would be super helpful thanks!
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.
A few quick thoughts:
createStatusMachine or defineStatusTransitions API would be far too limited and focused on a specific use casecreateSlice would have to work for the "builder callback" syntax in extraReducers, not just the reducers fieldI'm open to discussions on the topic in general, but this really isn't a high priority for us right now.
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;
@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:
extraReducers.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:
reducerWithMachine.js cons:
reducerFromMachine util, it is not obvious and probably more complicated than defineStatusTransitions reducerWithSlice.js pros:
reducerWithSlice.js cons:
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?
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.
@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
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:
slices if define the machine before reducer because XState requires access to actions.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.
Most helpful comment
@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: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:
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:
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
predicateprop in the same way asprepareworks.So it would be possible to avoid this:
And write this instead:
Sure it has no sense, as the first example is more concise.
But my proposal is:
So in my proposal, there are some benefits:
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