Xstate: XState + Redux + React

Created on 21 Feb 2019  路  6Comments  路  Source: davidkpiano/xstate

I needed to manage entities globally and I couldn't use GraphQL. So I thought Redux would be a good fit for holding global data, and then my statecharts could subscribe to the slice of global data they need.

I would like to share my draft implementation.

Step 1: create the global store

const reducer = (state, { type, entities }) => {
  switch (type) {
    case "RECEIVE_ENTITIES":
      return merge({}, state, { entities })

    default:
      return state
  }
}

const store = Redux.createStore(reducer)

Step 2: pass the store down

We want to support SSR, so we need to pass the store down through context:

const StoreContext = React.createContext()

<StoreContext.Provider value={store}>
  <App />
</StoreContext.Provider>

Step 3.1: create a request

createRequest is just a helper to create an invokable promise service.

const createRequest = request => async context => {
  const { data } = await axios(request)
  const { entities } = request.schema ? normalize(data, request.schema) : {}

  if (entities) context.store.dispatch({ type: "RECEIVE_ENTITIES", entities })

  return data
}

Usage:

const myMachine = Machine({
  states: {
    foo: {
      invoke: {
        src: createRequest({ method: "get", url: "/users" })
      }
    }
  }
})

const store = useContext(StoreContext)
const [state, send] = useMachine(myMachine, { context: { store } })

Step 3.2: create a subscription

We also want to subscribe to the global store and update our context in response to changes.

createSubscription is just a helper to create an invokable callback service.

const createSubscription = (selector, createEvent) => context => callback => {
  let prevState = context.store.getState()

  const send = () => {
    const nextState = context.store.getState()

    if (shallowEqual(prevState, nextState)) return

    callback(createEvent(selector(nextState)))

    prevState = nextState
  }

  send()

  const unsubscribe = context.store.subscribe(send)

  return () => unsubscribe()
}

Usage:

// global selector
const getUsers = state => state.entities.users

const myMachine = Machine({
  states: {
    foo: {
      invoke: [
        {
          // createSubscription accepts a selector and pass the result
          // to the second param, which creates an event that is sent to the machine
          // whenever the global store updates the relevant state slice returned
          // by the selector
          src: createSubscription(getUsers, users => ({
            type: "RECEIVE_USERS",
            users
          }))
        }
      ],
      on: {
        RECEIVE_USERS: {
          actions: assign({ users: (_, event) => event.users })
        }
      }
    }
  }
})

const store = useContext(StoreContext)
const [state, send] = useMachine(myMachine, { context: { store } })

Bonus Step: multiple subscriptions

const myMachine = Machine({
  states: {
    foo: {
      invoke: [
        {
          src: createSubscription(getUsers, users => ({
            type: "RECEIVE_USERS",
            users
          }))
        },
        {
          src: createSubscription(getPosts, posts => ({
            type: "RECEIVE_POSTS",
            posts
          }))
        },
        invoke: {
          src: createRequest({ method: "get", url: "/me" }),
          onDone: {
            target: "bar",
            actions: "setCurrentUser"
          }
        }
      ],
      on: {
        RECEIVE_USERS: {
          actions: assign({ users: (_, event) => event.users })
        },
        RECEIVE_POSTS: {
          actions: assign({ posts: (_, event) => event. posts })
        }
      }
    }
  }
})

Redux: holds global state only, no side-effects.
Statecharts: handle all side-effects. The state is local but can subscribe to the global state using createSubscription.

What I didn't like about it is that I need to pass the store to the machine context. The reason is that I don't want to create the services/subscriptions at rendering time, I want to create them when defining my machine.

I mean, my view layer shouldn't know how to create requests and subscriptions, it's an implementation detail of my statecharts.

const store = useContext(StoreContext)

// Bad: here I'm kind of coupling my view to machine's implementation details
const myMachine = Machine({
  services: {
    loadUsers: () => createSubscription(store, getUsers, users => ({
      type: "RECEIVE_USERS",
      users
    }))
  }
})

// Good: my view is totally dumb
const [state, send] = useMachine(myMachine, { context: { store } })

Passing the store to machine context is a trade-off. The context should be serializable, but JSON.stringify(store) returns {} so it's not an issue.

The issue is that I'm kind of violating the Actor model, because instead of passing a message, I'm passing the store implementation to child services (createSubscription / createRequest). I don't know how to solve it without breaking SSR or coupling my view to machine's implementation details. Thoughts?

documentation question

Most helpful comment

All this code is not necessary. @davidkpiano explained that XState should not know about Redux ("they're acquaintances, not friends").

To avoid coupling between XState and Redux the best solution is something like:

const getUsers = state => state.entities.users
const receiveEntities = entities => ({ type: "RECEIVE_ENTITIES", entities })

const { users } = useMappedState(state => ({ users: getUsers(state) }))
const dispatch = useDispatch()

const [state, send] = useMachine(myMachine, {
  context: {
    users
  },
  actions: {
    mergeEntities: (context, event) => dispatch(receiveEntities(event.data.entities))
  }
})

All 6 comments

After talking to @davidkpiano in Gitter I think I found a possible solution (untested):

const forwardableEvents = ["RECEIVE_ENTITIES"]

const forwardEvent = store => event => {
  if (forwardableEvents.includes(event.type)) store.dispatch(event)
}

const storeUpdate = state => ({ type: "STORE_UPDATE", state })

const useMachine = machine => {
  const store = useContext(StoreContext)

  const [state, setState] = useState(() => machine.initialState)
  const [service] = useState(() => interpret(machine))

  useEffect(() => {
    service
      .onEvent(forwardEvent(store))
      .onTransition(state => setState(state))
      .start()

    return () => service.stop()
  }, [])

  useEffect(() => {
    const unsubscribe = store.subscribe(() =>
      service.send(storeUpdate(store.getState()))
    )

    return () => unsubscribe()
  }, [])

  return useMemo(() => [state, service.send], [state])
}

const createSubscription = (selector, createEvent) => () => (
  callback,
  onEvent
) => {
  let prevState = null

  onEvent(event => {
    if (event.type !== "STORE_UPDATE") return

    const nextState = event.state

    if (shallowEqual(prevState, nextState)) return

    callback(createEvent(selector(nextState)))

    prevState = nextState
  })
}

const createRequest = request =>
  Machine({
    id: "request",
    context: { request },
    initial: "loading",
    states: {
      loading: {
        invoke: {
          src: async ({ request }) => {
            const { data } = await axios(request)
            return request.schema ? normalize(data, request.schema) : data
          },
          onDone: {
            target: "success",
            actions: sendParent((_, event) => ({
              type: "RECEIVE_ENTITIES",
              entities: event.data.entities
            }))
          },
          onError: "failure"
        }
      },
      success: {
        type: "final",
        data: { data: (_, event) => event.data }
      },
      failure: {
        type: "final",
        data: { error: (_, event) => event.data }
      }
    }
  })

// Usage

const myMachine = Machine({
  states: {
    foo: {
      invoke: [
        {
          src: createSubscription(getUsers, users => ({
            type: "RECEIVE_USERS",
            users
          })),
          forward: true
        },
        {
          src: createRequest({ url: "/posts" })
        }
      ],
      on: {
        RECEIVE_USERS: {
          actions: assign({ users: (_, event) => event.users })
        }
      }
    }
  }
})

All this code is not necessary. @davidkpiano explained that XState should not know about Redux ("they're acquaintances, not friends").

To avoid coupling between XState and Redux the best solution is something like:

const getUsers = state => state.entities.users
const receiveEntities = entities => ({ type: "RECEIVE_ENTITIES", entities })

const { users } = useMappedState(state => ({ users: getUsers(state) }))
const dispatch = useDispatch()

const [state, send] = useMachine(myMachine, {
  context: {
    users
  },
  actions: {
    mergeEntities: (context, event) => dispatch(receiveEntities(event.data.entities))
  }
})

@hnordt Thanks for the example. How do you keep redux data in sync with xstate?

In your example, if users changes in the redux store, useMappedState will take care to return a new { users } value. So far so good. But the useMachine hook puts the machine in a ref that is initialized once, so it will have an out of date copy of the users at that point.

@stevemolitor I haven't thought this through extensively, but I would try out putting store into machine's context. IMHO there is little point of hiding it and it just removes the synchronization problem entirely, because you can always just use getState in your machine.

And yes - ideally Redux and XState wouldn't need to know about each other, but decoupling them while keeping state up to date just causes you to do some unnecessary hoops in the code. It's also not that you have to use stored store directly, you can handle getting an actual slice of the state that you are interested in through selectors and such.

@Andarist

. It's also not that you have to use stored store directly, you can handle getting an actual slice of the state that you are interested in through selectors and such.

Yeah, here's a crude variation of @hnordt's example that passes a function to the context, that the machine can use to grab the latest users:

const getUsers = state => state.entities.users
const receiveEntities = entities => ({ type: "RECEIVE_ENTITIES", entities })

const { users } = useMappedState(state => ({ users: getUsers(state) }))
const selectUsers = () => users;
const dispatch = useDispatch()

const [state, send] = useMachine(myMachine, {
  context: {
    selectUsers,
  },
  actions: {
    mergeEntities: (context, event) => dispatch(receiveEntities(event.data.entities))
  }
})

The only changes are:

const selectUsers = () => users;
//...

const [state, send] = useMachine(myMachine, {
  context: {
    selectUsers,
    //...

Smth like that works as well. What I want to say is that being pragmatic is better than being dogmatic about it. Write code which is easier to maintain and reason about.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

3plusalpha picture 3plusalpha  路  3Comments

greggman picture greggman  路  3Comments

amelon picture amelon  路  3Comments

carlbarrdahl picture carlbarrdahl  路  3Comments

bradwoods picture bradwoods  路  3Comments