Skip to content
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

Can't transition back in history #188

Closed
djkirby opened this issue Oct 3, 2018 · 21 comments
Closed

Can't transition back in history #188

djkirby opened this issue Oct 3, 2018 · 21 comments

Comments

@djkirby
Copy link
Contributor

djkirby commented Oct 3, 2018

Bug or feature request?

Bug, I believe.

Description:

When transitioning to a history state, the machine remains in the same state. It seems like it might be resolving the state by going forward to the history state and then back one in history to the current state.

// machine
{
  initial: "step1",
  states: {
    step1: { on: { A: "step2" } },
    step2: { on: { B: "step3" } },
    step3: {},
    history: { history: true }
  },
  on: { BACK: "history" }
}

send("A"); // 'step2'
send("B"); // 'step3'
send("BACK"); // --> 'step3'

(Bug) Expected result:

The machine's state reverts back to the previous state in history.

(Bug) Actual result:

The machine's state remains in the same state.

Link to reproduction or proof-of-concept:

BACK on the machine: https://codesandbox.io/s/j31v21xvyv
BACK in a step: https://codesandbox.io/s/1zqzv81l97

In xstate@next, the value ends up as {"history":{}}:

BACK on the machine in next: https://codesandbox.io/s/pwr4k2rmv7
BACK in a step in next: https://codesandbox.io/s/88mx737n5l

@carloslfu
Copy link
Contributor

Hi Dylan. The last active state of the machine, before the BACK event, is step3. So if you target the history state, the state is gonna be step3.

@carloslfu
Copy link
Contributor

carloslfu commented Oct 4, 2018

@djkirby, an implementation of the machine you are trying to do would be:

{
  initial: "step1",
  states: {
    step1: { on: { A: "step2" } },
    step2: { on: { B: "step3", BACK: "step1" } },
    step3: { on: { BACK: "step2" } },
  },
}

@djkirby
Copy link
Contributor Author

djkirby commented Oct 4, 2018

@carloslfu yea that's what I'm trying to avoid, because you don't always know where to go back to. That was a simple example but say step1 or step2 both took me to step3 -- I just want to go back to where it was previously. Think of a dynamic wizard flow. Maybe this isn't expected to be possible with the library.

@carloslfu
Copy link
Contributor

carloslfu commented Oct 4, 2018

Yeah, I get it. But, I think explicitness is better. Right now I working in a wizard and when I draw the flow it is very important to me to have a clear understanding of what is the next step for a given event.

I guess your use case is something like global navigation, in this case, you're right this is not the right approach, so the UI can enter one of those states and you what to go back. Here there are two cases.

First, if you have to associate those states with URLs then the best approach is to integrate xstate with a router, I found ui-router is a good fit.

Second, if you don't have to associate it with URLs, implement it externally. This way you do it with an action that does the work at the interpreter level. Take a look at this implementation:

import { Machine } from "xstate";
import { interpret } from "xstate/lib/interpreter";

let interpreter;
const history = [];

const machine = Machine(
  {
    initial: "step1",
    states: {
      step1: { on: { A: "step2" } },
      step2: { on: { B: "step3", PREV: "step1" } },
      step3: { on: { PREV: "step2" } },
    },
    on: { BACK: { actions: "back" } }
  },
  {
    actions: {
      back: () => {
        history.pop();
        interpreter.init(history[history.length - 1]);
      }
    }
  }
);

interpreter = interpret(machine);

interpreter.onTransition(state => {
  history.push(state);
  console.log(state.value);
});

interpreter.init();

interpreter.send("A");
interpreter.send("B");
interpreter.send("BACK");

console.log("actual state:", interpreter.state.value);

Live demo: https://codesandbox.io/s/q77lypnx1w

As you are implementing a wizard, use the PREV event to going to the previous step using a go-to-previous button. And, use the BACK event for going to the previous state, like undo functionality. Notice this way you can easily implement a FORWARD event too, just like redo functionality.

@djkirby
Copy link
Contributor Author

djkirby commented Oct 5, 2018

Ok if it isn't expected functionality to step backward arbitrarily, I'll close this issue.

@djkirby djkirby closed this as completed Oct 5, 2018
@davidkpiano davidkpiano reopened this Oct 5, 2018
@davidkpiano
Copy link
Member

I'll reopen it because I still want to verify that the behavior is correct for these cases:

  • Transition is a self-transition on the current state node (internal or external)
  • History exhibits the correct shallow/deep behavior on self-transition

@craigglennie
Copy link

craigglennie commented Nov 21, 2018

Is the code above a complete example for handling undo in xstate? If so, it might be good to add that to the docs, I can see it being something people would use (not sure if there's a "recipes" for section it could maybe go in?)

@diegopamio
Copy link

What if I'm not interested in doing the following: some step can skip a step, so when I'm in the target step and I want to go back, I'd like to go back to the original previous step, considering if it was skipped or not:

import { Machine } from 'xstate';

const toggleMachine = Machine({
  initial: 'step1',
  states: {
    step1: { on: { next: 'step2', skipStep2: 'step3'}},
    step2: { on: { next: 'step3', BACK: 'step1' }}, 
    step3: { on: { BACK: 'step????'}}
  }
});

let currentState = toggleMachine.initialState;

function send(event) {
  currentState = toggleMachine.transition(currentState, event);
  console.log(currentState.value);
}

//Case #1
send('next'); // 'step2'
send('next'); // 'step3'
send('BACK'); // want to go back to step2

//Case #2
send('skipStep2'); // 'step2'
send('next'); // 'step3'
send('BACK'); // want to go back to step1

@djkirby
Copy link
Contributor Author

djkirby commented Feb 9, 2019

@diegopamio exactly, I am also wondering how to handle that case.

@Dirklectisch
Copy link

I assumed history states where used to fix the use case @diegopamio described. Why would you want to transition into a history state to revert back to the place you came from? I can't get it to work that way though.

@djkirby
Copy link
Contributor Author

djkirby commented Apr 22, 2019

I'll reopen it because I still want to verify that the behavior is correct for these cases:

  • Transition is a self-transition on the current state node (internal or external)
  • History exhibits the correct shallow/deep behavior on self-transition

@davidkpiano just wondering if you had a chance to look into this at all. I can try to dig into it if that helps. I'll close this issue if this functionality isn't expected from the library -- just unsure if this is a bug or a feature request.

@vctt94
Copy link

vctt94 commented Jan 9, 2020

I have similar behavior, but I am not sure if it is possible to achieve it.

I have a State Machine which can go from multiple states to another state (for example the settings page, which one can make some changes and then go back). So when going back from the settings page, I want to go back to the last accessed state.

Is it possible to do?

@Andarist
Copy link
Member

Andarist commented Jan 9, 2020

@vctt94 it sounds like a good use case for history states

@gpietro
Copy link

gpietro commented May 4, 2020

I'm trying to do something similar:
States:

  • created
  • ready
  • in_progress

can go to a state suspended, ON_RESUME: I need to go back to the previous state that could be created, ready or in_progress.

@davidkpiano
Copy link
Member

davidkpiano commented May 4, 2020

@gpietro That is possible if those created/ready/in_progress states are in a nested state:

active: {
  initial: 'in_progress',
  states: {
    created: {},
    ready: {},
    in_progress: {},
    previous: { type: 'history' }
  },
},
suspended: {
  on: { RESUME: 'active.previous' }
}

@awreccan
Copy link

https://codesandbox.io/s/xstate-state-machine-with-history-stack-6iq32?file=/src/index.js
^Demo of how to implement history with a back button. My example use case revolves around navigation through "screens" of a user flow through a products screens, like in https://overflow.io/examples/#wireframe-user-flows.

Thanks for this awesome library @davidkpiano, it's a pleasure working with something this well considered. Hope this demo makes sense!

@sean-beard
Copy link

sean-beard commented Jan 24, 2023

What if I'm not interested in doing the following: some step can skip a step, so when I'm in the target step and I want to go back, I'd like to go back to the original previous step, considering if it was skipped or not:

import { Machine } from 'xstate';

const toggleMachine = Machine({
  initial: 'step1',
  states: {
    step1: { on: { next: 'step2', skipStep2: 'step3'}},
    step2: { on: { next: 'step3', BACK: 'step1' }}, 
    step3: { on: { BACK: 'step????'}}
  }
});

let currentState = toggleMachine.initialState;

function send(event) {
  currentState = toggleMachine.transition(currentState, event);
  console.log(currentState.value);
}

//Case #1
send('next'); // 'step2'
send('next'); // 'step3'
send('BACK'); // want to go back to step2

//Case #2
send('skipStep2'); // 'step2'
send('next'); // 'step3'
send('BACK'); // want to go back to step1

I just ran into this scenario and solved it by using guarded transitions.

const toggleMachine = Machine({
  initial: "step1",
  states: {
    step1: { on: { next: "step2", skipStep2: "step3" } },
    step2: { on: { next: "step3", BACK: "step1" } },
    step3: {
      on: {
        BACK: [
          {
            target: "step1",
            cond: (_context, event) => event.meta.previousStep === "step1",
          },
          {
            target: "step2",
          }
        ]
      }
    }
  }
});

Then define the previousStep in the meta object when sending the BACK transition.

send({ type: "BACK", meta: { previousStep: current.history?.value } });

Hopefully this helps someone else!

@dbismut
Copy link

dbismut commented Feb 11, 2023

@sean-beard thanks for this. Unfortunately, this solution doesn't work going back into nested states.

// state change sequence

state.value = { views: 'step0', status: 'idle' } // #0 state.history is empty
state.value = { views: 'step1', status: 'fetching' } // #1 state.history is { views: 'step0', status: 'idle' }
state.value = { views: 'step1', status: 'idle' } // #2 state.history is { views: 'step1', status: 'fetching' }

Therefore I can't target views.step0 from #2.

@Andarist
Copy link
Member

@dbismut can u post a complete example of what you are trying to do? A codesandbox maybe?

@dbismut
Copy link

dbismut commented Feb 11, 2023

I'll try to work on this asap @Andarist. I have a working solution which is based on a comment from @davidkpiano I read in some other thread (which I can't find at the moment), where I think he suggested to have "wizard" steps inside a context.stack array. My version of this isn't super elegant though. Let me get back to this.

@dbismut
Copy link

dbismut commented Feb 11, 2023

@Andarist here it is https://codesandbox.io/s/wizard-machine-6k6tbs

It's a bit of a contrived example, and I'm a bit new to all this. But the general idea is:

  • each step of the wizard may have some data fetching to be performed prior to being displayed (hence the loading state)
  • the user should be able to go back to the previous step

I'd be sort of ok with my implementation, but I wish I would be able to have only one function to commit the state to the stack:

// What I have
states: {
    zipCode: { entry: "commitZipCode", always: { target: "idle" } },
    userAddresses: {
        invoke: {
           // ...
           onDone: { actions: "commitUserAddresses", target: "idle" }
        }
    },
},
actions: {
    commitZipCode: assign({
        stack: ({ stack }) => [...stack, "zipCode"]
    }),
    commitUserAddresses: assign({
        stack: ({ stack }) => [...stack, "userAddresses"]
    }),
}

// What I'd like => same function to commit the state
states: {
    zipCode: { entry: "commit", always: { target: "idle" } },
    userAddresses: {
        invoke: {
           // ...
           onDone: { actions: "commit", target: "idle" }
        }
    },
},
actions: {
    commit: assign({
        stack: ({ stack }, _, { state }) => [...stack, state.value]
    }),
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests