Skip to content

Instantly share code, notes, and snippets.

@UberMouse
Last active March 26, 2020 22:42
Show Gist options
  • Save UberMouse/7f944e46cc4be00fc332d80b1b58a3d6 to your computer and use it in GitHub Desktop.
Save UberMouse/7f944e46cc4be00fc332d80b1b58a3d6 to your computer and use it in GitHub Desktop.
RxJS + XState
import { isArray, some } from "lodash";
import { Observable } from "rxjs";
import { useObservable } from "rxjs-hooks";
import {
interpret,
EventObject,
StateMachine,
Typestate,
StateSchema,
Interpreter,
StateValue
} from "xstate";
import { pluck } from "../../rxjs";
type TransitionMessage<
TContext,
TEvent extends EventObject,
TTypestate extends Typestate<TContext>,
TState extends TTypestate["value"]
> = {
context: TContext;
event: TEvent;
matches: (state: TState | TState[]) => boolean;
state: string;
};
type MatchProps<
TContext,
TStateSchema extends StateSchema,
TEvent extends EventObject,
TTypestate extends Typestate<TContext>,
TState extends TTypestate["value"],
TInterpreter extends Interpreter<TContext, TStateSchema, TEvent, TTypestate> = Interpreter<
TContext,
TStateSchema,
TEvent,
TTypestate
>
> = {
state: TState | TState[];
children: (args: {
context: Extract<TTypestate, { value: TState | TState[] }>["context"];
send: TInterpreter["send"];
}) => JSX.Element;
};
export function buildService<
TContext,
TStateSchema extends StateSchema,
TEvent extends EventObject,
TTypestate extends Typestate<TContext>,
TState extends TTypestate["value"],
TInterpreter extends Interpreter<TContext, TStateSchema, TEvent, TTypestate> = Interpreter<
TContext,
TStateSchema,
TEvent,
TTypestate
>
>(
machine: StateMachine<TContext, TStateSchema, TEvent, TTypestate>
): {
current$: Observable<TransitionMessage<TContext, TEvent, TTypestate, TState>>;
context$: Observable<TContext>;
send: TInterpreter["send"];
service: TInterpreter;
Match: (
props: MatchProps<TContext, TStateSchema, TEvent, TTypestate, TState, TInterpreter>
) => JSX.Element | null;
} {
const service = interpret(machine);
const current$ = new Observable<TransitionMessage<TContext, TEvent, TTypestate, TState>>(sub => {
service.onTransition(state => {
sub.next({
context: state.context,
event: state.event,
state: stateValueToString(state.value),
// TState does not match up with what state.matches expects
// But we know that the runtime values will match up from other
// guarantees
// eslint-disable-next-line @typescript-eslint/no-explicit-any
matches: (s: TState | TState[]) => state.matches(s as any) as boolean
});
});
});
const context$ = current$.pipe(pluck("context"));
function Match({
state,
children
}: MatchProps<TContext, TStateSchema, TEvent, TTypestate, TState, TInterpreter>) {
const currentState = useObservable(() => current$);
const context = useObservable(() => context$);
if (currentState === null || context === null) {
return null;
}
const matched = isArray(state)
? some(state, s => currentState.matches(s))
: currentState.matches(state);
if (matched) {
return children({ context, send: service.send });
}
return null;
}
return {
current$,
context$,
send: service.send,
service: service as TInterpreter,
Match
};
}
/**
* StateValues can either be a simple state ("state"), or a complex state ({state: "subState"})
* complex states can be nested so we need recursively unpack them
*
* @param s the state to turn into a.human.readable.string
*/
function stateValueToString(s: StateValue) {
if (typeof s === "string") {
return s;
}
const outer = Object.keys(s)[0];
const inner = stateValueToString(Object.values(s)[0]);
return `${outer}.${inner}`;
}
import produce from "immer";
import { createMachine, assign } from "xstate";
import { buildService } from "@kx/utils/lib/xstate";
import { FeatureChange } from "~modules/repository/api";
import { ChangeTypes } from "../../types";
type IdStr = string;
export type Events =
| { type: "TOGGLE_CHANGE_TYPE"; changeType: ChangeTypes; changeId: IdStr }
| { type: "ADD_CHANGE"; change: FeatureChange }
| { type: "REMOVE_CHANGE"; id: IdStr }
| { type: "RESET" };
export type Context = {
changes: Record<IdStr, FeatureChange>;
filters: Record<IdStr, Record<ChangeTypes, boolean>>;
};
export type State =
| { value: "noChanges"; context: Context }
| { value: "singleChange"; context: Context }
| { value: "multipleChanges"; context: Context };
const defaultContext: Context = Object.freeze({
changes: {},
filters: {}
});
const addChangeAction = "addChange" as const;
const removeChangeAction = "removeChange" as const;
const actions = {
addChange: addChangeAction,
removeChange: removeChangeAction
};
const guards = {
onlyTwoChanges: "onlyTwoChanges" as const
};
export const machine = createMachine<Context, Events, State>(
{
id: "diff-machine",
context: defaultContext,
initial: "noChanges",
on: {
RESET: {
target: "noChanges",
actions: assign(() => defaultContext)
}
},
states: {
noChanges: {
on: {
ADD_CHANGE: {
target: "singleChange",
actions: actions.addChange
}
}
},
singleChange: {
on: {
REMOVE_CHANGE: {
target: "noChanges",
actions: actions.removeChange
},
ADD_CHANGE: {
target: "multipleChanges",
actions: actions.addChange
}
}
},
multipleChanges: {
on: {
REMOVE_CHANGE: [
{
target: "singleChange",
cond: guards.onlyTwoChanges,
actions: actions.removeChange
},
{ target: "multipleChanges", actions: actions.removeChange }
],
ADD_CHANGE: {
target: "multipleChanges",
actions: actions.addChange
}
}
}
}
},
{
actions: {
[actions.removeChange]: assign({
changes: (ctx, e) =>
e.type === "REMOVE_CHANGE" ? removeChange(ctx.changes, e.type) : ctx.changes
}),
[actions.addChange]: assign({
changes: (ctx, e) =>
e.type === "ADD_CHANGE" ? addChange(ctx.changes, e.change) : ctx.changes
})
},
guards: {
[guards.onlyTwoChanges]: ctx => Object.keys(ctx.changes).length === 2
}
}
);
function addChange(changes: Context["changes"], change: FeatureChange) {
return produce(changes, draft => {
draft[change.id] = change;
});
}
function removeChange(changes: Context["changes"], id: IdStr) {
return produce(changes, draft => {
delete draft[id];
});
}
const { Match, context$, current$, send, service } = buildService(machine);
service.start();
export {
Match as DiffMatch,
context$ as diffContext$,
current$ as diffState$,
send as diffSend,
service as DiffService
};
import React from "react";
import { combineLatest } from "rxjs";
import { useObservable } from "rxjs-hooks";
import { map } from "rxjs/operators";
import { pluck } from "@kx/utils/lib/rxjs";
import { diffContext$ } from "../../../machines";
import { DiffViewer } from "../../DiffViewer";
const state$ = combineLatest(
[diffContext$.pipe(pluck("filters")), diffContext$.pipe(pluck("changes"), map(Object.values))],
(filters, changes) => ({ filters, changes })
);
export function ActiveDiff() {
const { changes, filters } = useObservable(() => state$) ?? {};
if (!changes || !filters) {
return null;
}
return <DiffViewer changeTypesToDisplayById={filters} diffs={changes} />;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment