Skip to content

Commit

Permalink
feat(builders): builders 2.0
Browse files Browse the repository at this point in the history
This implements #14 xstate-tree machine building 2.0

A new single builder function for constructing xstate-tree machines, createXStateTreeMachine, has been created
which replaces the previous build* functions, which have been deprecated.

This function provides better UX by eliminating the boilerplate of having to use 4 separate builder functions
and allowing the omitting of selector/action/slots arguements for machines that do not need them. Providing the context
object via the selectors by default.
  • Loading branch information
UberMouse committed Nov 29, 2022
1 parent 4c8ad20 commit f285acd
Show file tree
Hide file tree
Showing 14 changed files with 528 additions and 407 deletions.
101 changes: 40 additions & 61 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,7 @@ import { createRoot } from "react-dom/client";
import { createMachine } from "xstate";
import { assign } from "@xstate/immer";
import {
buildSelectors,
buildActions,
buildView,
buildXStateTreeMachine,
createXStateTreeMachine
buildRootComponent
} from "@koordinates/xstate-tree";

Expand All @@ -35,10 +32,6 @@ type Events =
| { type: "INCREMENT"; amount: number };
type Context = { incremented: number };

// If this tree had more than a single machine the slots to render child machines into would be defined here
// see the codesandbox example for an expanded demonstration that uses slots
const slots = [];

// A standard xstate machine, nothing extra is needed for xstate-tree
const machine = createMachine<Context, Events>(
{
Expand Down Expand Up @@ -74,39 +67,42 @@ const machine = createMachine<Context, Events>(
}
);

// Selectors to transform the machines state into a representation useful for the view
const selectors = buildSelectors(machine, (ctx, canHandleEvent) => ({
canIncrement: canHandleEvent({ type: "INCREMENT", amount: 1 }),
showSecret: ctx.incremented > 10,
count: ctx.incremented
}));

// Actions to abstract away the details of sending events to the machine
const actions = buildActions(machine, selectors, (send, selectors) => ({
increment(amount: number) {
send({
type: "INCREMENT",
amount: selectors.count > 4 ? amount * 2 : amount
});
const RootMachine = createXStateTreeMachine(machine, {
// Selectors to transform the machines state into a representation useful for the view
selectors({ ctx, canHandleEvent, inState }) {
return {
canIncrement: canHandleEvent({ type: "INCREMENT", amount: 1 }),
showSecret: ctx.incremented > 10,
count: ctx.incremented,
active: inState("active")
}
},
switch() {
send({ type: "SWITCH_CLICKED" });
}
}));

// A view to bring it all together
// the return value is a plain React view that can be rendered anywhere by passing in the needed props
// the view has no knowledge of the machine it's bound to
const view = buildView(
machine,
selectors,
actions,
slots,
({ actions, selectors, inState }) => {
// Actions to abstract away the details of sending events to the machine
actions({ send, selectors }) {
return {
increment(amount: number) {
send({
type: "INCREMENT",
amount: selectors.count > 4 ? amount * 2 : amount
});
},
switch() {
send({ type: "SWITCH_CLICKED" });
}
}
},

// If this tree had more than a single machine the slots to render child machines into would be defined here
// see the codesandbox example for an expanded demonstration that uses slots
slots: [],
// A view to bring it all together
// the return value is a plain React view that can be rendered anywhere by passing in the needed props
// the view has no knowledge of the machine it's bound to
view({ actions, selectors }) {
return (
<div>
<button onClick={() => actions.switch()}>
{inState("active") ? "Deactivate" : "Activate"}
{selectors.active ? "Deactivate" : "Activate"}
</button>
<p>Count: {selectors.count}</p>
<button
Expand All @@ -118,15 +114,7 @@ const view = buildView(
{selectors.showSecret && <p>The secret password is hunter2</p>}
</div>
);
}
);

// Stapling the machine, selectors, actions, view, and slots together
const RootMachine = buildXStateTreeMachine(machine, {
selectors,
actions,
view,
slots
},
});

// Build the React host for the tree
Expand All @@ -145,16 +133,16 @@ Each machine that forms the tree representing your UI has an associated set of s
- Selector functions are provided with the current context of the machine, a function to determine if it can handle a given event and a function to determine if it is in a given state, and expose the returned result to the view.
- Action functions are provided with the `send` method bound to the machines interpreter and the result of calling the selector function
- Slots are how children of the machine are exposed to the view. They can be either single slot for a single actor, or multi slot for when you have a list of actors.
- View functions are React views provided with the output of the selector and action functions, a function to determine if the machine is in a given state, and the currently active slots
- View functions are React views provided with the output of the selector and action functions, and the currently active slots

## API

To assist in making xstate-tree easy to use with TypeScript there are "builder" functions for selectors, actions, views and the final XState tree machine itself. These functions primarily exist to type the arguments passed into the selector/action/view functions.
To assist in making xstate-tree easy to use with TypeScript there is the `createXStateTreeMachine` function for typing selectors, actions and view arguments and stapling the resulting functions to the xstate machine

* `buildSelectors`, first argument is the machine we are creating selectors for, second argument is the selector factory which receives the machines context as the first argument. It also memoizes the selector factory for better rendering performance
* `buildActions`, first argument is the machine we are creating actions for, the second argument is the result of `buildSelectors` and the third argument is the actions factory which receives an XState `send` function and the result of calling the selectors factory. It also memoizes the selector factory for better rendering performance
* `buildView`, first argument is the machine we are creating a view for, second argument is the selector factory, third argument is the actions factory, fourth argument is the array of slots and the fifth argument is the view function itself which gets supplied the selectors, actions, slots and `inState` method as props. It wraps the view in a React.memo
* `buildXStateTreeMachine` takes the results of `buildSelectors`, `buildActions`, `buildView` and the list of slots and returns an xstate-tree compatible machine
`createXStateTreeMachine` accepts the xstate machine as the first argument and takes an options argument with the following fields, it is important the fields are defined in this order or TypeScript will infer the wrong types:
* `selectors`, receives an object with `ctx`, `inState`, and `canHandleEvent` fields. `ctx` is the machines current context, `inState` is the xstate `state.matches` function to allow determining if the machine is in a given state, and `canHandleEvent` accepts an event object and returns whether the machine will do anything in response to that event in it's current state
* `actions`, receives an object with `send` and `selectors` fields. `send` is the xstate `send` function bound to the machine, and `selectors` is the result of calling the selector function
* `view`, is a React component that receives `actions`, `selectors`, and `slots` as props. `actions` and `selectors` being the result of the action/selector functions and `slots` being an object with keys as the slot names and the values the slots React component

Full API docs coming soon, see [#20](https://github.com/koordinates/xstate-tree/issues/20)

Expand Down Expand Up @@ -203,15 +191,6 @@ It is relatively simple to display xstate-tree views directly in Storybook. Sinc

There are a few utilities in xstate-tree to make this easier

#### `buildViewProps`
This is a builder function that accepts a view to provide typings and then an object containing
actions/selector fields. With the typings it provides these fields are type safe and you can autocomplete them.

It returns the props object and extends it with an `inState` factory function, so you can destructure it for use in Stories. The `inState` function accepts a state string as an argument, and returns a function that returns true if the state supplied matches that. So you can easily render the view in a specific machine state in the Story
```
const { actions, selectors, inState } = buildViewProps(view, { actions: {], selectors: {} });
```

#### `genericSlotsTestingDummy`

This is a simple Proxy object that renders a <div> containing the name of the slot whenever rendering
Expand Down
95 changes: 43 additions & 52 deletions examples/todomvc/App.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import {
broadcast,
buildActions,
buildSelectors,
buildView,
buildXStateTreeMachine,
multiSlot,
Link,
RoutingEvent,
createXStateTreeMachine,
} from "@koordinates/xstate-tree";
import { assign } from "@xstate/immer";
import React from "react";
Expand All @@ -29,7 +26,6 @@ type Events =
| RoutingEvent<typeof completedTodos>;

const TodosSlot = multiSlot("Todos");
const slots = [TodosSlot];
const machine =
/** @xstate-layout N4IgpgJg5mDOIC5QBcD2FUFoCGAHXAdAMaoB2EArkWgE4DEiouqsAlsq2YyAB6ICMAFgBMBAJwTBANgkBWAOwAOWQGYpwgDQgAngMEEp-RYNn8FABmHmxMlQF87WtBhz46AZQCaAOQDCAfQAVAHkAEWD3bmY2Di4kXkRMeSkCEWTzRUUbYUV5c1ktXQRMflKCMyUxQXk1Y2FShyd0LDxcDwAJYIB1fwBBX0CASQA1AFEgsIiolnZOUm4+BBUagitzFWFZasVzczMpQsThFeVFFV35fjzLQUaQZxa3d06e3oAZN4nwyPjo2bjQIt+FJFKsxNYxLIbLJNoIxIpDsUVFDUsJBGcVGIrPxjrdHPdmq42s9uv5fMEALIABTeo0Co1CXymvxmsXm8UWJWsBB2ljUVThe2EBx0iWRYlR6JUmOxuIc+NI6Dg3AeROIZEo1FQNGmMTmC0SslBVRqIJE-HywsEgkRVwIlWqan40tM-DuqtaBEVgWa8BZeoBCQQV1EUhUFSyjrNNtFwZs4kjpysKkUwjU7sJnoAFthYD6MH6mKz9RzDeYCDCwxGTfzbfH4VUk+tU+n8R78Lr-uzAYkLeW0lIMll1Ll8ojMGiUhGzkIU2JWw4gA */
createMachine(
Expand Down Expand Up @@ -111,48 +107,50 @@ const machine =
}
);

const selectors = buildSelectors(machine, (ctx) => ({
get count() {
const completedCount = ctx.todos.filter((t) => t.completed).length;
const activeCount = ctx.todos.length - completedCount;
export const TodoApp = createXStateTreeMachine(machine, {
selectors({ ctx, inState }) {
return {
get count() {
const completedCount = ctx.todos.filter((t) => t.completed).length;
const activeCount = ctx.todos.length - completedCount;

return ctx.filter === "completed" ? completedCount : activeCount;
},
get countText() {
const count = this.count;
const plural = count === 1 ? "" : "s";

return `item${plural} ${ctx.filter === "completed" ? "completed" : "left"}`;
},
allCompleted: ctx.todos.every((t) => t.completed),
haveCompleted: ctx.todos.some((t) => t.completed),
allTodosClass: ctx.filter === "all" ? "selected" : undefined,
activeTodosClass: ctx.filter === "active" ? "selected" : undefined,
completedTodosClass: ctx.filter === "completed" ? "selected" : undefined,
}));

const actions = buildActions(machine, selectors, () => ({
addTodo(title: string) {
const trimmed = title.trim();
return ctx.filter === "completed" ? completedCount : activeCount;
},
get countText() {
const count = this.count;
const plural = count === 1 ? "" : "s";

if (trimmed.length > 0) {
broadcast({ type: "TODO_CREATED", text: trimmed });
}
},
completeAll(completed: boolean) {
broadcast({ type: "TODO_ALL_COMPLETED", completed });
return `item${plural} ${
ctx.filter === "completed" ? "completed" : "left"
}`;
},
allCompleted: ctx.todos.every((t) => t.completed),
haveCompleted: ctx.todos.some((t) => t.completed),
allTodosClass: ctx.filter === "all" ? "selected" : undefined,
activeTodosClass: ctx.filter === "active" ? "selected" : undefined,
completedTodosClass: ctx.filter === "completed" ? "selected" : undefined,
hasTodos: inState("hasTodos"),
};
},
clearCompleted() {
broadcast({ type: "TODO_COMPLETED_CLEARED" });
actions() {
return {
addTodo(title: string) {
const trimmed = title.trim();

if (trimmed.length > 0) {
broadcast({ type: "TODO_CREATED", text: trimmed });
}
},
completeAll(completed: boolean) {
broadcast({ type: "TODO_ALL_COMPLETED", completed });
},
clearCompleted() {
broadcast({ type: "TODO_COMPLETED_CLEARED" });
},
};
},
}));

const view = buildView(
machine,
selectors,
actions,
slots,
({ inState, actions, selectors, slots }) => {
slots: [TodosSlot],
view({ actions, selectors, slots }) {
return (
<>
<section className="todoapp">
Expand All @@ -170,7 +168,7 @@ const view = buildView(
/>
</header>

{inState("hasTodos") && (
{selectors.hasTodos && (
<>
<section className="main">
<input
Expand Down Expand Up @@ -234,12 +232,5 @@ const view = buildView(
</footer>
</>
);
}
);

export const TodoApp = buildXStateTreeMachine(machine, {
view,
actions,
selectors,
slots,
},
});
Loading

0 comments on commit f285acd

Please sign in to comment.