Huge thanks to the XState & Legend-State teams <3
- Observable and Computed context
- Optimized React hooks
- A supporting test suite
Provides a core library (legend-xstate) usable with vanilla xstate and a React sub-library (legend-xstate/react) with XState React hooks optimized for Legend-State
Please see the XState and Legend-State docs if you're not already familiar with either library.
import { interpret } from 'xstate';
import { computed } from '@legendapp/state';
import { createObservableMachine } from 'legend-xstate';
const countMachine = createObservableMachine<{ count: number; computed: { doubled: number; doubledDoubled: number } }>({
initial: 'start',
// Automatically transformed into observable context
context: {
count: 0,
},
// Object with computed values that can reference context or other computed values
computed: (context) => ({
doubled: computed(() => context.count.get() * 2),
doubledDoubled: computed(() => context.computed.doubled.get() * 2),
}),
states: {
start: {
on: {
INC: {
// Update context Legend-State's observable style
action: assign((context) => context.count.set((c) => c + 1)),
},
DEC: {
action: assign((context) => context.count.set((c) => c - 1)),
},
},
},
},
});
const service = interpret(countMachine).start();
service.send({ type: 'INC' });
service.state.context.count.peek(); // 1
service.state.context.computed.doubled.peek(); // 2
service.state.context.computed.doubledDoubled.peek(); // 4Installation:
- yarn:
yarn add legend-xstate - npm:
npm i legend-xstate - pnpm:
pnpm add legend-xstate
Required peer dependencies for legend-xstate:
Required peer dependencies for legend-xstate/react:
createObservableMachine is a replacement for XState's createMachine that turns context into an Observable and introduces a new computed config property.
contextis an object that is automatically transformed into an Observable.computedis a callback function providing the machine'scontextand should return an object withcomputedvalues from Legend-State.
computed values are mapped to context.computed
import { createObservableMachine } from 'legend-xstate';
import { computed } from '@legendapp/state';
const machine = createObservableMachine<{ count: number; computed: { doubled: number } }>({
initial: 'idle',
context: { count: 1 },
computed: (context) => ({
doubled: computed(() => context.count.get() * 2),
}),
states: {},
});A helper generic type accepting the shape of the context object and optionally the shape of the computed object.
import { ObservableContext, createObservableMachine } from 'legend-xstate';
createObservableMachine<ObservableContext<{ count: number }, { doubled: number }>>({
context: { count: 1 },
computed: (context) => ({
doubled: computed(() => context.count.get() * 2),
}),
});Overrides XState's assign function to allow updates in the observable without the need to return a value.
// context { count: 0 }
const actions = {
inc: assign((context) => context.count.set((c) => c + 1)),
dec: assign((context) => context.count.set((c) => c - 1)),
};observableContext(context, computed?): Context & {computed: Computed}: Produces an observable context object with optional computed values. XState's context update flow requires the root context to be an object and not a Proxy, soobservableContextadds all the methods of an observable onto context. It is recommended to usecreateObservableMachinebefore usingobservableContext- args:
contextXState contextcomputedAn optional callback function that provides theContextas a value and returns an object with computed values
const context = observableContext(
// context
{
count: 1,
},
// computed callback
(context) => ({
doubled: computed(() => context.count.get() * 2),
doubledDoubled: computed(() => context.computed.doubled.get() * 2),
})
);
context.count.peek(); // 1
context.computed.doubled.peek(); // 2- Actors need to be wrapped in
opaqueObjectfrom@legendapp/stateif they are stored in context - If a computed is returning an
Observablerather than the base value, the type passed intoContextmust be wrapped inObservableValue. (I'm looking into ways to make this less annoying) contextis a pseudo observable, meaning it's not a Proxy, but it has the same methods as an observable. This shouldn't cause any issues (except you'll need to usestate.context.get()in React when rendering the root context object), but it's worth noting.
legend-xstate/react Exports optimized versions of useMachine and useActor from @xstate/react as useObservableMachine and useObservableActor that only rerender when absolutely necessary. Components using machines/actors have the same fine-grained reactivity from as you'd expect with Legend-State, a Component only rerenders when the state.value changes; state.can, state.matches, state.hasTags, etc. are run accordingly.
You can still use useMachine and useActor from @xstate/react but will lose out on more optimized component rerenders without manual memoization.
It's important to wrap your components in @legendapp/state's observer wrapper.
The tests for useObservableMachine and useObservableActor are all ported from @xstate/react (thanks @xstate/react team :))
useObservableActor: Optimized version of@xstate/react'suseActorwith the same inputs and outputs.useObservableMachine: Optimized version of@xstate/react'suseMachinewith the same inputs and outputs.
import { observer, enableLegendStateReact } from '@legendapp/state/react';
import { useObservableMachine } from 'legend-xstate/react';
enableLegendStateReact();
const Counter = observer(() => {
// the full component will never re-render because `state.value` never changed
const [state, send] = useObservableMachine(counterMachine);
return (
<div>
count is {state.context.count} // Changes to count will not rerender the whole component
<button onClick={() => send({ type: 'INC' })}>INC</button>
<button onClick={() => send({ type: 'DEC' })}>DEC</button>
</div>
);
});- Add more tests
- Better docs
-
Install:
yarn -
Run:
yarn -
Run:
yarn run build:legend -
To test run:
yarn run test
