Xstate: useMachine Hook Improvement

Created on 4 Feb 2019  Â·  28Comments  Â·  Source: davidkpiano/xstate

https://xstate.js.org/docs/recipes/react.html#hooks

If we change:

return [current, service.send];

To:

return useMemo(() => [current, service.send], [current]);

We unlock a nice provider feature:

function ChatMachineProvider({ children }) {
  return (
    <ChatMachineContext.Provider value={useMachine(chatMachine)}>
      {children}
    </ChatMachineContext.Provider>
  )
}
documentation

Most helpful comment

Wait, why can't I just do const service = interpret(machine);? There's no reason to wrap that in any hook.

All 28 comments

Also, the hook should be changed to use useState instead of useMemo when starting the service. The Hooks API reference insists that useMemo doesn't guarantee that the function will not be called again. The alternative is to pass a function to useState. This function will only be called on the first render.

So the hook becomes (without including the change suggested by @hnordt):

import { useState, useEffect } from 'react';
import { interpret } from 'xstate';

export function useMachine(machine) {
  // Keep track of the current machine state
  const [current, setCurrent] = useState(machine.initialState);

  // Start the service (only once!)
  const [service] = useState(
    () =>
      interpret(machine)
        .onTransition(state => {
          // Update the current machine state when a transition occurs
          setCurrent(state);
        })
        .start()
  );

  // Stop the service when the component unmounts
  useEffect(() => {
    return () => service.stop();
  }, []);

  return [current, service.send];
}

It would be a good idea to move the side effects to useEffect:

import { useState, useEffect } from 'react';
import { interpret } from 'xstate';

export function useMachine(machine) {
  // Keep track of the current machine state
  const [current, setCurrent] = useState(machine.initialState);

  // Start the service (only once!)
  const [service] = useState(() => interpret(machine));

  // Start the service when the component mounts and stop when it unmounts
  useEffect(() => {
    service.onTransition(state => {
          // Update the current machine state when a transition occurs
          if (state.changed) setCurrent(state);
        })
        .start()
    return () => service.stop();
  }, []);

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

@esrch I'm wondering, why useState instead of useRef?

```js
import { useRef, useEffect } from 'react';
import { interpret } from 'xstate';

export function useMachine(machine) {
// Keep track of the current machine state
const [current, setCurrent] = useState(machine.initialState);

// Start the service (only once!)
const serviceRef = useRef(interpret(machine));

// Start the service when the component mounts and stop when it unmounts
useEffect(() => {
serviceRef.current.onTransition(state => {
// Update the current machine state when a transition occurs
if (state.changed) setCurrent(state);
})
.start()
return () => serviceRef.current.stop();
}, []);

return useMemo(() => [current, serviceRef.current.send], [current]);
}
````

Wait, why can't I just do const service = interpret(machine);? There's no reason to wrap that in any hook.

@davidkpiano it would break suspense and SSR I think.

Also, it would point to the same instance every time, so multiple useMachine won't work.

Every call of useMachine is supposed to generate a different machine service instance anyway.

The following code would break useMachine because then every instance of useMachine will point to the same service:

const service = interpret(machine);

export function useMachine(machine) {
  // ...

  useEffect(() => {
    service.onTransition(setCurrent).start()
    return () => service.stop();
  }, []);

/// ...
}

The following code would break useMachine because then every rerender would create a new service instance:

export function useMachine(machine) {
  // ...

  const service = interpret(machine);

  useEffect(() => {
    service.onTransition(setCurrent).start()
    return () => service.stop();
  }, []);

/// ...
}

So we need to useRef to guarantee we'll have the same instance on every rerender.

@hnordt I pasted your code in the codesandbox for the TodoMVC example, and it didn't work. Apparently, you need to change back the if (state.changed) setCurrent(state) to setCurrent(state) to make it work. Beyond that (and missing useState and useMemo imports), it seems to work.

I figured it would be best to have useState with a function argument rather than useRef so that we avoid re-creating an interpreter by calling interpret(machine) on every rerender.

A ref is where you should store the service, not in state. You will want to follow the lazy instantiation example for useRef from https://reactjs.org/docs/hooks-faq.html#how-to-create-expensive-objects-lazily

eg

const serviceRef = useRef(null);

// ✅ Service is created lazily once
function getService() {
  let service = serviceRef.current;
  if (service !== null) {
    return service;
  }
  let newService = interpret(machine);
  serviceRef.current = newService;
  return newService;
}

// When you need it, call getService()

I prefer the setState version. It's simpler.

I agree that the useState version is a bit simpler, but shortening the getService function doesn't make it too bad, I think:

const serviceRef = useRef(null);

const getService = () =>
  serviceRef.current || (serviceRef.current = interpret(machine))

useEffect(() => {
  getService().onTransition(setCurrent).start()
  return () => getService().stop();
}, []);

@cdomigan Is there a specific drawback to using useState rather than useRef?

@esrch only semantically in this case. It’s not render state - we won’t be updating it ever and the render function will never use the value.

FWIW:

I use a different approach on my side. I have only one big state machine for entire client, and one websocket connection that i receive commands (events) from my server, tldr: every user command is send to my server, and the server control the client state sending the appropriate (events).

In this cenário ia have a custom useMachine that behaves more similar to redux, like this:

export function useMachine<R = object>(redux: (state: ReducerType) => R): R {
    const initial = useMemo(() => {
        const param = getParam(interp.state); // Current State
        return redux(param);
    }, [machine]);
    const updater = useReducer(s => !s, false)[1];
    const ref = useRef({ value: initial, redux, updater });
    useEffect(() => {
        reduceres.add(ref);
        return () => {
            reduceres.delete(ref);
        };
    }, [machine]);
    return ref.current.value;
}
 interp.onTransition((state, event) => {
            const p = getParam(state);
            reduceres.forEach(element => {
                const old = element.current.value;
                element.current.value = element.current.redux(p);
                const equal = isEqual(element.current.value, old);
                if (!equal) {
                    element.current.updater(equal);
                }
            });
        });

And can be used like this:

    const action = "Auth.login"
    const { canSend } = useMachine(state => ({
        canSend: state.nextEvents.includes(action)
    }));
function Count(){
    const { count } = useMachine((state) => ({
        count: state.context.App.example.count
    }))
    return (
        <div className={Count.name}>
        <br/>
        <span> Regent {count }</span>
        </div>
      )
}

Using this only the when the context, state, or anything that user is observing using the reducer function change that will trigger the render on react.

Is working very good. Im actually running the xstate on the server side too. But in this case i'm running several microservices (with moleculerjs) and the xstate is helping to keep all stateful services on sync. ( also multi threaded)

I'm implementing some gimmicks to keep the context ease to handler too. Maybe i can share the solution when is done.

using the hook provided on the docs makes this warning show up:

Application state or actions payloads are too large making Redux DevTools serialization slow and consuming a lot of memory. See https://git.io/fpcP5 on how to configure it.

and also starts crashing Chrome frequently :/

@geddski please create a simplified version of your code in CodeSandbox so we can try to reproduce the error.

@geddski Set devTools to false:

const service = interpret(machine, { devTools: false });

In the next minor version, this will be false by default, so you'd have to explicitly add:

const service = interpret(machine, { devTools: true });

I'll also find ways to reduce the payload.

I tried to reproduce it - https://codesandbox.io/s/9329kxl4r4?fontsize=14
but it doesn't happen there, so it must be the fact that I'm using so many state machines in my app?

@geddski the crashes happened to me too. The issue is that I was saving instances in context. The whole chatClient instance. context is not supposed to be used that way, the contents need to be serializable.

You might want to make sure you are not saving non-serializable stuff into context. In the future, I hope XState warns about it instead of crashing devTools.

@hnordt interesting thx, I'll scout for that in my app.

@davidkpiano ahh I tracked it down to the fact that I'm passing a DOM element along with the send command:

const [current, send] = useMachine(machine);

useEffect(
    () => {
      if (props.hasSelection) {
        send({ type: "open", el: el.current });
      } else {
        send({ type: "close", el: el.current });
      }
    },
    [props.hasSelection]
  );

@geddski yeah, that's the kind of misuse I was talking about. Both events and context should be serializable.

TIL! I'll need to think of another way to make a ref available to an action then...

@geddski the statechart can notify something happened and then you provide the action when initializing the hook:

const [state, send] = useMachine(myMachine, {
  actions: {
    notifySuccess: () => myRef.current.doSomething()
  }
})

TIL! I'll need to think of another way to make a ref available to an action then...

Not sure what you're trying to do with the ref but I'd have a hash map (object or Map) of string -> Element somewhere, so I can refer to element refs by string IDs. Bonus: makes it more testable:

send({ type: 'open', el: 'fooPanel' });

@hnordt that indeed solves the problem of creating a new Machine instance every render. What does your useMachine hook look like? The one in the docs doesn't take a second argument.

const useMachine = (machine, { context = {}, actions = {}, services = {} } = {}) => {
  const [state, setState] = useState(() => machine.initialState)
  const [service] = useState(() =>
    interpret(
      machine
        .withConfig({ actions, services }, merge({}, machine.context, context))
    )
  )

  useEffect(() => {
    service.onTransition(state => setState(state)).start()
    return () => service.stop()
  }, [])

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

The second argument is redundant, since you can just do:

const [current, send] = useMachine(someMachine
  .withConfig({ ... }, customContext));

If anything, the second argument should pass options for interpret(machine, options).

@davidkpiano the reason is that I want to avoid having to merge context every time:

useMachine(machine.withContext({ ...machine.context, ...overrides }))

Fixed in latest @xstate/react.

Was this page helpful?
0 / 5 - 0 ratings

Related issues

doup picture doup  Â·  3Comments

greggman picture greggman  Â·  3Comments

hnordt picture hnordt  Â·  3Comments

ifokeev picture ifokeev  Â·  3Comments

drmikecrowe picture drmikecrowe  Â·  3Comments