Code Monkey home page Code Monkey logo

xstate-router's Introduction

xstate-router

XState Router. Add routes to your XState machine and maintain it in sync with the actual route.

Use

Install the library with npm i xstate-router.

If you don't have XState installed, install it: npm i xstate

Try the live example here: https://codesandbox.io/s/rllly3pyxp.

The routerMachine function returns an interpreter:

import { routerMachine } from 'xstate-router'

const machineConfig = {
    initial: 'main',
    context: { myValue: 0 },
    states: {
        main: { meta: { path: '/' } },
        blog: { meta: { path: '/blog' } },
    },
}

const service = routerMachine({
    config: machineConfig,
    options,
    initialContext,
})

// The state changes on a route change and the route changes on a state change.
service.onTransition(state => console.log(state.value))

// The context is enhanced with router properties.
service.onChange(ctx => console.log(ctx))
/* Context
    {
        myValue: 0,
        // Router properties:
        match,
        location,
        history,
    }
*/

Use with React Hooks

import { useRouterMachine } from 'xstate-router'

const config = {
  initial: 'home',
  states: {
    home: { meta: { path: '/' }, on: { NEXT: 'about' } },
    about: { meta: { path: '/about' }, on: { NEXT: 'dashboard' } },
    dashboard: {
      meta: { path: '/dashboard' },
      initial: 'login',
      on: { NEXT: 'home' },
      states: {
        loggedIn: {
          initial: 'main',
          states: {
            main: { meta: { path: '/dashboard/main' } },
            data: { meta: { path: '/dashboard/data' } }
          }
        },
        login: {
          meta: { path: '/dashboard/login' },
          on: { LoggedIn: 'loggedIn' }
        }
      }
    }
  }
}

function App() {
    const service = useRouterMachine({ config })

    return <div>{service.state.value}</div>
}

Enhanced context

  1. match: Tells you whether the route in the location matches the current state's path. If it matches it contains an object holding properties for each route parameter's value if the path was parameterized. Examples: null (not matching), {} (no parameters), { param1: 4711 }
  2. location: The current value of history.location
  3. history: routerMachine(...) accepts a history object as fourth parameter. If it is missing it defaults to createBrowserHistory() (from package 'history') and is published in the context.

if you translate to a state having a parameterized route then you have to ensure that context.match contains the values of those parameters. Otherwise the placeholder is shown in the route. Example:

  states: {
      list: { meta: { path: '/items' },
         on: {
            ShowDetails: {
                target: 'details',
                actions: assign((ctx, event) => ({
                                    ...ctx,
                                    match: { id: event.item }
                                }))
            }
         }
      }
      details: { meta: { path: '/items/:id/details'} }
  }

where the event trigger could look like this:

<button onClick={() => this.send('ShowDetails', { item: 817 })}>Show details...</button>

Paths

Paths could have parameters such as /items/:id/details and regular expressions, for more information please read this: https://github.com/pillarjs/path-to-regexp.

Router events

If a route changes then a parameterized event 'route-changed' is fired: e.g. { dueToStateTransition: "true", route: "/blog", service: /* the xstate interpreter */ }.

  1. If the route changes because a state is entered which has a route configured, then dueToStateTransition is true. If the route changes because the location was changed (either by the user in the browsers location bar or by a script changing history.location), then dueToStateTransition is false.
  2. route gives you the current route which causes the event
  3. service provides the xstate interpreter which can be used to send another event.

Placing an on: 'router-changed' event at a state can be used to avoid leaving the current state if the route changes. Think of a state which might show unsaved data and you want to ask the user 'Leave and loose unsaved data?'. If you decide to accept the new route anyway you have to resend the event:

  on: {
    'route-changed': {
      cond: (context, event) => event.dueToStateTransition === false
          && !event.processed,            // interfere only new events
      actions: (context, event) => {
        if (context.unsavedData) return;  // suppress current route change
        event.processed = true;           // mark event as processed
        event.service.send(event);        // resend the event to establish the origin route change
      }
    }
  },

xstate-router's People

Contributors

carloslfu avatar dependabot[bot] avatar franke1276 avatar joernroeder avatar laheadle avatar stephanpelikan avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar

xstate-router's Issues

Adopt parameterized routes to current values

If one sends an event which targets a state having a parameterized route and the context reflects this parameters in the "match" property like it would be as typing a parameterized route into the browsers location bar then the route shown in the browsers location bar does no show the current parameters but the placeholders instead.

I adopted the sandbox example (https://codesandbox.io/s/xstate-router-5r5r7) like this:

  • line 6: import { assign } from 'xstate/lib/actions'
  • line 13: about: { meta: { path: '/about' }, on: { NEXT: { target: 'dashboard.loggedIn.data', actions: assign(ctx => ({ ...ctx, match: { id: 815 } })) }}},
  • line 23: data: { meta: { path: '/dashboard/:id/data' } }

Loading https://5r5r7.codesandbox.io/#/dashboard/4711/data gives

State: {"dashboard":{"loggedIn":"data"}}

as expected. If you load https://5r5r7.codesandbox.io/#/about and press the "Next" button you will get

https://5r5r7.codesandbox.io/#/dashboard/**:id**/data

as location instead of https://5r5r7.codesandbox.io/#/dashboard/**815**/data

I've added a sandbox-fork which show this: https://codesandbox.io/s/xstate-router-no0r3

Test failing after merge

After merging #3 , there is a test failing, this needs investigation in order to be able to release a new version to NPM.

Test: XStateRouter โ€บ When enter a state having a parameterized route, the route sho
uld reflect the current param value. - line 180

    expect(received).toBe(expected) // Object.is equality

    Expected: "/substate/817/c"
    Received: "/substate/undefined/c"

      178 |     const { getByTestId } = renderWithRouter(App, { route: '/about' })
      179 |     fireEvent.click(getByTestId('go-substate-c'))
    > 180 |     expect(getByTestId('location-display').textContent).toBe('/substate/817/c')
          |                                                         ^
      181 |   })

route-changed event triggered after xstate.init on the same route

I'm noticing that route-changed events are triggered even if the machine gets initialized on that particular route. This leads to duplicate actions on state entry and invocations of services at the target route.

I've isolated the behavior in this sandbox:
https://codesandbox.io/s/xstate-router-forked-fyff8

my suggestion is to omit the route-changed event and not trigger it if the route did not change or the previous event was xstate.init.

Enhancements

Hi Carlos (@carloslfu),

Awesome library you have started here. Using the concepts, I re-implemented this library for personal use in JS/ES6 instead of TS. In the process, I may of simplified some of the state/route debouncing and initialization logic and also added a couple features I needed.

Please take a look and feel free to use the enhancements at: https://codesandbox.io/s/xstate-routed-machines-hpbl7

By adding a dynamically built transient initial state to the config, the routed machine now starts in the correct state when initializing at a path other than the 'initial' state's route. The metadata for a routed state node now uses a route object that allows for more control over matching and switching routes

meta: {
    route: {
        path: '/somePath/:someNamedParam/(orARegex)?',
        exact: false,  //allows for partial matching from start note the path '/' would match everything
        priority: -1, //allows to prioritizing which route to match when several routes may match.
        replace: false //if set to true will use history.replace instead of history.push to update the path
    }
}

Thanks for your hard work,
Tim

Unable to use `routerMachine` in a functional component

The routerMachine works with a class component like a charm. Except, in an all-functional components project like mine it fails to run and produces the following infiniate rerending error:

index.js:2178 Warning: Maximum update depth exceeded. This can happen when a component calls setState inside useEffect, but useEffect either doesn't have a dependency array, or one of the dependencies changes on every render....

Here's my code:

const Enrolment = () => {
  const options = {
    actions: {
      ...
    },
  };
  const initialContext = {};
  const { state, send } = routerMachine({
    config: enrolmentMachineConfig,
    options,
    initialContext,
    history,
  });

  const renderComponent = () => {
    switch (state.value) {
      case 'introduction':
        return <Introduction />;
      case 'emailCheck':
        return <EmailCheck />;
        ...
      default:
        return null;
    }
  };

  return (
    <EnrolmentMachineContextProvider value={state.context}>
      <EnrolmentMachineStateProvider value={state.value}>
        <EnrolmentMachineSendProvider value={send}>
          <>
            <Header />
            {renderComponent()}
            <Footer />
          </>
        </EnrolmentMachineSendProvider>
      </EnrolmentMachineStateProvider>
    </EnrolmentMachineContextProvider>
  );
};
export default Enrolment;

I tried using useService from @xstate/react but that still didn't help, and it seems that's because routerMachine triggers service.onTransition on every render.

The way I solved this problem is by forking your lib, refactoring routerMachine so to reuse its functionality and created a new function: useRouterMachine.

I refactored routerMachine function with the to the following:

export function createRouterMachine<
  TContext = any,
  TState extends StateSchema = any,
  TEvent extends EventObject = any
>({
  config,
  options = ({} as MachineOptions<TContext, TEvent>),
  initialContext = {},
  history = createBrowserHistory(),
}: RouterArgs): StateMachine<TContext, TState, TEvent> {
  const routes = getRoutes(config)
  const enhancedConfig = addRouterEvents(history, config, routes)
  const currentLocation = history.location
  const enhancedContext = {
    ...initialContext,
    match: resolve(routes, currentLocation),
    location: currentLocation,
    history
  }

  return Machine(enhancedConfig, options, enhancedContext);
}

export function routerMachine<
  TContext = any,
  TState extends StateSchema = any,
  TEvent extends EventObject = any
>({
  config,
  options = ({} as MachineOptions<TContext, TEvent>),
  initialContext = {},
  history = createBrowserHistory(),
}: RouterArgs) {
  const machine = createRouterMachine({config, options, initialContext, history})
  const service = interpret(machine)
  service.start()

  handleTransitionEvents(service, history, getRoutes(config))

  return service
}

export function useRouterMachine
<
  TContext = any,
  TState extends StateSchema = any,
  TEvent extends EventObject = any
>({
  config,
  options = ({} as MachineOptions<TContext, TEvent>),
  initialContext = {},
  history = createBrowserHistory(),
}: RouterArgs) {
  const machine = createRouterMachine({config, options, initialContext, history})
  const [state, send, service] = useMachine(machine);

  useEffect(() => {
    handleTransitionEvents(service, history, getRoutes(config))
  }, [])

  return {state, send, service};
}

export function handleTransitionEvents (service, history, routes) {
  let debounceHistoryFlag = false
  let debounceState = false
  handleRouterTransition(history.location)

  service.onTransition(state => {
    const stateNode = getCurrentStateNode(service, state)
    const path = findPathRecursive(stateNode)
    if (debounceState
        // debounce only if no target for event was given e.g. in case of 
        // fetching 'route-changed' events by the user
        && debounceState[1] === path) {
      debounceState = false
      return
    }
    if (!matchURI(path, history.location.pathname)) {
      debounceHistoryFlag = true
      const uri = buildURI(path, state.context.match)
      history.push(uri)
      service.send({ type: routerEvent, dueToStateTransition: true, route: path, service: service })
    }
  })

  history.listen(location => {
    if (debounceHistoryFlag) {
      debounceHistoryFlag = false
      return
    }
    handleRouterTransition(location, true)
  })

  function handleRouterTransition(location, debounceHistory?: boolean) {
    let matchingRoute
    for (const route of routes) {
      const params = matchURI(route[1], location.pathname)
      if (params) {
        matchingRoute = route
        break
      }
    }
    if (matchingRoute) {
      debounceState = matchingRoute[1]  // debounce only for this route
      service.send({ type: routerEvent, dueToStateTransition: false, route: matchingRoute[1], service: service })
      const state = service.state.value
      if (!matchesState(state, matchingRoute[0].join('.'))) {
        const stateNode = getCurrentStateNode(service, service.state)

        if (stateNode.meta && stateNode.meta.path) {
          if (debounceHistory) {
            debounceHistoryFlag = true
          }
          history.replace(stateNode.meta.path)
        }
      }
    }
  }
}

What do you think? Do you think it's worth including this change and renaming the project to xstate-react-router ?

By the way, I noticed you have 15 months old repo called xstate-react-router which has a different type of implementation. I am not sure what you intend to do with that project although it seemed interesting enough to have a component wrapper around the react-router Routes.

  • A little digression: I wanted to note that your NPM for xstate-router is pointing to xstate-react-router on github rather than xstate-router, I think this is a mistake.

Thanks a bunch!

Issa

doesn't work with xstate 4.7

It seems like the graph methods were moved to a separate repo @xstate/graph so this package doesn't work with xstate@^4.7.0.

I know it hasn't been updated in a while, but I haven't found another solution for a wizard form.

[Bug] Not unsubscribing on unmont from history

I ran into a bug with your library which caused the state machine to "reactivate itself" even tho the parent component had unmounted before. It turned out that you're not unsubscribing from history changes here

history.listen(location => {

// unsubscribe should be returned
return history.listen ...

and

handleTransitionEvents(service, history, getRoutes(config))

// return unsubscribe in the useEffect hook for proper cleanup
return  handleTransitionEvents

React router vs xstate router ?

hi,

I found this project via your react router xstate integration repo, it seems to be abandoned. (last commit beginning of 2019)

This is the recommended repository for integrating a router in react using xstate ?

So I presume this replaces react router and just used pure xstate ?

Any help really appreciated

Thanks

Browser back button / history pop causes an infinite loop

I am using the useRouterMachine where all states have a path in each meta object.

  const history = createBrowserHistory();
  const { state, send, service } = useRouterMachine({
    config,
    options,
    initialContext,
    history,
  });

Each state has a NEXT and PREV event to navigate between states forwards and backwards. This works without a problem.

But the minute I click the browser back button, which triggers history action POP, nothing happens on the page (meaning it doesn't jump back) and the browser console spits out this message:

Warning: Event "route-changed" was sent to stopped service "(machine)". This service has already reached its final state, and will not transition.
Event: {"type":"route-changed","dueToStateTransition":false,"route":"/enrolment/declaration","service":{"id":"(machine)"}}

Now since useRouterMachine uses useMachine from @xstate/react I realised that it stops the service on unmount https://github.com/davidkpiano/xstate/blob/master/packages/xstate-react/src/index.ts#L97 .. and on history POP the component gets unmounted, hence pushing event route-changed results in this error.

The issue exacerbates the second I fire any other event like NEXT on my state machine, which triggers an infinite loop (resulting in the same warning in the console showing like 10 times / second) which breaks the browser and I have to force close it.

I have a solution for this, will attach a PR shortly.

How to handle intermediate states that shouldn't have a path?

In case I want to mix states that should have routes together with states that shouldn't (due to their transitive nature) in the same machine, how do I express this? Just leaving out the meta: { path: '/whatever } will drive the router into a runtime-error, setting path to "null" doesn't change the outcome. Thanks in advance.

Failed to compile due to object rest spread

Hi @carloslfu. Thank you very much for this useful library.

I tried to make it work in my react-app/typescript project but I am getting the following error when I build my project with your lib:

./node_modules/xstate-router/lib/index.js
Module parse failed: Unexpected token (68:21)
You may need an appropriate loader to handle this file type.
| exports.getRoutes = getRoutes;
| function addRouterEvents(history, configObj, routes) {
|     const config = { ...configObj };
|     if (!config.on) {
|         config.on = {};

This is due to the "target" set in your tsconfig. I forked your project and changed

    "target": "es2018",

to

    "target": "es2016",

This causes the JS build to change from

    const config = { ...configObj };

to

    const config = Object.assign({}, configObj);

After making this change in my fork, my project builds with your lib without issues.

Prevent / defer state transitions on route changes

Hello,

I'm evaluating to use your library for state based routing. In our application there are states which must not be leaved immediately on changing the route. In some situations data needs to be safed using a REST-API before the state transition takes place (deferred state transition). In other situations in which the user fills a form I want to ask him for really changing the state (and may be loosing data) which might gives a deferred state transition (user chooses "yes") or no state transition (user chooses "no") and a "rollback" of the browsers history.
I'm not sure how to achieve this behavior. A first idea was to model events for route changes because for thinking in states and events this is also an event like hitting the submit button of a form.

Studding your code a little bit I could see that the library adds additional events to trigger transitions once the user enters a new route. My requirement shows that at least in some situations it is the responsibility of the application to model which events should be processed, isn't it? I understand that you use events because you store routing-data in the machine's context which is not allowed to be changed without an event. Additionally you want to provide this as an out-of-the-box functionality.

So my questions are:

  1. Wouldn't it be a good modification of your library to accept an additional, optional meta-config-property (e.g. auto-target: false which defaults to true) to avoid setting a target on the additional events (https://github.com/carloslfu/xstate-router/blob/master/src/index.tsx#L60)?
  2. If this does the trick, would it be necessary to add functions to "commit" or "rollback" the changed route as I mentioned above?
  3. Do you see another way to get the desired behavior without tweaking your library?

If you think my suggestions are good then I could fork your project and try to do the modifications to send you a pull request. But I think I will need your help. What do you think?

Cheers,
Stephan

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.