statelyai / rfcs

RFCs for XState and Stately tools
44 stars 5 forks source link

`@xstate/react` Global hooks API #8

Closed davidkpiano closed 1 year ago

davidkpiano commented 2 years ago

Rendered text

davidkpiano commented 2 years ago

Overview:

import { createHooks } from '@xstate/react';
import { authMachine } from './authMachine';
import { interpret } from 'xstate';

// Interpret the machine first (full interpretation control)
const authService = interpret(authMachine, {/* ... */}).start();

// Create a "hooks object" from the actor
const auth = createHooks(authService);

// ...

const SomeForm = () => {
  // Get full state snapshot and send() function
  const [state, send] = auth.useActor();

  // Get selected state
  const isLoggedIn = auth.useSelector(state => state.hasTag('authorized'));

  // ...
}
mellson commented 2 years ago

I think the proposed API looks good, and I, for one, would like to remove React context for my shared machines 👍🏻

A few thoughts come to mind:

davidkpiano commented 2 years ago

Thanks for the feedback @mellson!

Re: the React context comparison, those are valid points. I'm going to research how Zustand handles this (Recoil provides a Provider), but we can add this to the API instead of having it "always global":


const auth = createHooks(authService);

export function App() {
  // ...

  return <auth.Provider>
    <MainPage />
  </auth.Provider>
}

export function Component() {
  const [state, send] = auth.useActor();

  // ...
}

This doesn't add too much to the proposed API, and has the benefits of providing a "scope" only where you need it.

farskid commented 2 years ago

The proposed API looks good in general. Simple and intuitive. I do agree with the point @mellson pointed out about the scope. I second @Andarist's questions about .start. What's the best place for it to be called, considering SSR and Concurrent React? We need what useInterpreter does for global singleton actors.

This will also play nicely with the system receptionist RFC.

And here's a link to a playground I made using the proposed API: https://codesandbox.io/s/xstate-rfc-shared-state-with-global-hooks-for-actors-ybxc5l?file=/src/App.tsx

davidkpiano commented 2 years ago

Pull request made: https://github.com/statelyai/xstate/pull/3401

@mattpocock Can you add your comments from that PR to this RFC? Specifically with regard to this:

Instead, createHooks should work based off a machine definition, not a running service. This solves both problems, but will require some API changes.

zacharyweidenbach commented 2 years ago

My concern with tightly coupling the idea of a provider with a running machine is that, in the case of a global store that might consist of many machines, you could end up with deeply nested providers that imply some hierarchy where there is none. I'm trying to think of other common use cases where this might be a problem. But effectively, you could end up with this:

const auth = createHooks(authService);
const user = createHooks(userService);
const settings = createHooks(settingsService);

export function App() {
  // ...

  return <auth.Provider>
    <user.Provider>
      <settings.Provider>
        <MainPage />
      </settings.Provider>
    </user.Provider>
  </auth.Provider>
}

export function Component() {
  const [state, send] = auth.useActor();

  // ...
}

But if instead you could compose services together under a single provider, it could make for a more extendable API

const StoreProvider = createProvider({
  auth: createHooks(authService),
  user: createHooks(userService),
  settings: createHooks(settingsService),
});

export const useStoreProvider = StoreProvider.useProvider;

export function App() {
  // ...

  return <StoreProvider>
    <MainPage />
  </StoreProvider>
}

export function Component() {
  const { auth } = useStoreProvider();
  const [state, send] = auth.useActor();
  // ...
}

This would add more boilerplate though in the case of one-off machines that simply need a single provider. It might also further complicate the question on when to call .start() on the provided services. I'm also unsure if it introduces the problem of excessive rerenders for downstream consumers of the provider, if they might rerender from changes to an unrelated machine in the provider that they do not care about.

davidkpiano commented 2 years ago

I added details on how the execution of the actor can be controlled. This maintains the original idea of giving the developer full control over when the actor is started:

// ...
// Actor not started yet
const authService = interpret(authMachine, {/* ... */});
const auth = createHooks(authService);

const App = () => {
  // If you must start the actor in a useEffect...
  useEffect(() => {
    auth.actorRef.start();
    // Since it's a global actor, no need for cleanup
  }, []);

  const [state, send] = auth.useActor();
  // ...
}
davidkpiano commented 1 year ago

This is now released as createActorContext()