Documentation / feature proposal
Hi, I just implemented my first state machine on my React/TypeScript project with xstate ! I followed the documentation but I faced some issues. I would like to present some technical solutions and code snippets that I wrote to overcome those issues:
If they are relevant I would be happy to open a PR :) Otherwise I welcome any feedback.
I created the state machine at a quite high level and I didn't want to drill props through 4-5 components, so I figured out I would put the machine in React context. However I found it quite painful to type the context.
export const MyMachineContext = createContext(null);
// infered type by TypeScript: const MyMachineContext: React.Context<null>
Initially the context is null so the type can't be inferred automatically by TypeScript. I created this utils to create the context:
import { createContext } from 'react';
import { EventObject, Interpreter, State } from 'xstate';
export type StateType<ContextType, EventType extends EventObject> = State<
ContextType,
EventType,
any
>;
export type SendType<ContextType, EventType extends EventObject> = Interpreter<
ContextType,
any,
EventType
>['send'];
export const createMachineContext = <ContextType, EventType extends EventObject>() =>
createContext<{
state: StateType<ContextType, EventType>;
send: SendType<ContextType, EventType>;
} | null>(null);
When consuming the React context, I faced another issue: TypeScript indicates that state and send properties are not always defined (because React context value is initially null).
export const ModuleLayoutContext = createMachineContext<ContextType, EventType>();
const SomeHighUpComponent: React.FC<{}> = () => {
const [state, send] = useMachine<ContextType, EventType>(moduleLayoutMachine);
...
return(
<ModuleLayoutContext.Provider value={{ state, send }}>
...
</ModuleLayoutContext>
)
}
const ConsumerComponent = () => {
const { state, send } = useContext({ machine: ModuleLayoutContext });
/* state and send are typed as possibly undefined by TypeScript even though
we initialized their value. */
This implies that I have to check for nullity every single time, even though I know I initialized the state machine higher up in the component tree with a useMachine hook. This is the utils I wrote:
import { useContext } from 'react';
import { EventObject, Interpreter, State } from 'xstate';
export type StateType<ContextType, EventType extends EventObject> = State<
ContextType,
EventType,
any
>;
export type SendType<ContextType, EventType extends EventObject> = Interpreter<
ContextType,
any,
EventType
>['send'];
export type MachineReactContextType<ContextType, EventType extends EventObject> = React.Context<{
state: StateType<ContextType, EventType>;
send: SendType<ContextType, EventType>;
} | null>;
type Props<ContextType, EventType extends EventObject> = {
machine: MachineReactContextType<ContextType, EventType>;
};
const useMachineContext = <ContextType, EventType extends EventObject>({
machine,
}: Props<ContextType, EventType>) => {
const context = useContext(machine);
if (!context) {
throw Error('State machine is not initialized');
}
return { state: context.state, send: context.send };
};
export default useMachineContext;
Because the hook throws an error when context is not defined, TypeScript can narrow the state and send type from SomethingType || undefined to SomethingType || never which is equivalent to SomethingType
Thanks for reading !
The createContext(null) problem is an existing "issue" with React TypeScript (that won't be fixed). Here is a workaround: https://gist.github.com/sw-yx/f18fe6dd4c43fddb3a4971e80114a052
Or you can do createContext(null as any).
@davidkpiano @AleBlondin
I found better solution:
import React from 'react';
import { Interpreter, State } from 'xstate';
export interface RootMachineContext {
id: string;
}
interface RootMachineToggleEvent {
type: 'TOGGLE';
id: string;
}
interface RootMachineScanEvent {
type: 'SCAN';
}
export type RootMachineEvent =
| RootMachineToggleEvent
| RootMachineScanEvent;
export const StateContext = React.createContext<
[
State<RootMachineContext, RootMachineEvent>,
Interpreter<RootMachineContext, any, RootMachineEvent>['send'],
Interpreter<RootMachineContext, any, RootMachineEvent>,
]
>([
{} as State<RootMachineContext, RootMachineEvent>,
((() => {}) as any) as Interpreter<
RootMachineContext,
any,
RootMachineEvent
>['send'],
{} as Interpreter<RootMachineContext, any, RootMachineEvent>,
]);
so far the following seems to work well for me when defining types for a machine:
type StateContext = {}
type StateEvent = { type: 'SOME_EVENT' }
type StateSchema = { states: { some_state: {} } }
type TypeState = Distribute<keyof StateSchema["states"], StateContext> // assuming same context
type Context = [
State<StateContext, StateEvent, StateSchema, TypeState>,
Interpreter<StateContext, StateSchema, StateEvent, TypeState>['send']
];
type Distribute<U, C> = U extends any ? { value: U; context: C } : never // util
const AppContext = React.createContext<Context>({} as any);
note the addition of TypeState which seems to be needed to make matches() work.
I just happened to find an interim solution for this by using constate.
function usePetFormMachine() {
return useMachine(petFormMachine)
}
export const [PetFormMachineProvider, usePetFormMachineContext] = constate(
usePetFormMachine,
)
function SomeComponent() {
const [current, send] = usePetFormMachineContext()
return current.value
}
function App() {
return (
<PetFormMachineProvider>
<SomeComponent />
</PetFormMachineProvider>
}
}
I know that using a utility library is not what this issue is about though. Just wanted to share in case it could help someone avoid the extensive type ritual while we have a more definite answer :D