React Loki and XState - Part 2
I'd like to spend the majority of this blog post explaining and demonstrating some design decisions that I hope will remove some difficulties my company was experiencing with the use of Redux middleware to create large dynamic applications.
You can read the previous introductory post if you'd like to receive some additional context.
The code for this post is in github: https://github.com/DaveWelling/rlx
The demo looks like this:
Hopefully I made it obvious that there are two sets of data (widgets and foos) that can be selected and changed within their own respective forms.
Here is the code for the main App component of the demo:
const rc = createElement;
const recordType0 = "widget";
const recordType1 = "foo";
export default function Application() {
// prettier-ignore
return rc(App, null,
rc(AppTitle, null, 'Hello React Loki XState'),
rc(EventBoundary, {logEvents: true},
rc(ActiveRecord, {recordType: recordType0},
rc(SummaryDetail, null,
rc('div', null,
rc(Grid, {recordType: recordType0}),
rc(ActionButton, {actionType: 'new', title: 'Add Widget'})
),
rc(WidgetForm)
)
)
),
rc(EventBoundary, {logEvents: true},
rc(ActiveRecord, {recordType: recordType1},
rc(SummaryDetail, null,
rc('div', null,
rc(Grid, {recordType: recordType1}),
rc(ActionButton, {actionType: 'new', title: 'Add Foo'})
),
rc(FooForm)
)
)
)
);
}
You probably noticed that I don't like waiting for JSX to be transformed in my tests. I've gotten very used to calling rc
(short for react.createElement
) directly. Hopefully that isn't too distracting. I actually prefer it over JSX now that I'm used to it.
I declare two record types at the beginning of this code. This is a very simple form of metadata needed to demonstrate the dynamic nature of the app. In other words, many of the same components will be used to render and manipulate these two types of data.
I introduce two unique components here as well. There is an EventBoundary and an ActiveRecord component.
The EventBoundary creates a React context which confines event messages to descendants of the EventBoundary. The event messages cannot escape into the wider application. In other words, a 'submit' button click event will only be visible to subscribers which are declared within the same boundary. It does this by encapsulating the subscriptions within a React context:
export const EventBoundaryContext = React.createContext();
export default function EventBoundaryProvider({ children, logEvents }) {
const context = {
subscriptions: {},
logEvents,
};
return rc(EventBoundaryContext.Provider, { value: context }, children);
}
Then the useEventSink
hook provides a very simple event sink that consumes that context to administer the event publications and subscriptions:
export default function useEventSink() {
const context = React.useContext(EventBoundaryContext);
if (context == null) {
throw new Error(
"The parent react component hierarchy must contain a EventBoundary component before the useEventSink hook can be used."
);
}
return eventSink(context);
}
Similarly, the ActiveRecord component allows all controls which wish to manipulate a 'foo' or 'widget' to know they are referring to the same 'foo' or 'widget', as long as they are descendants of the same ActiveRecord component.
These components borrow ideas that Kent Dodds wrote about in his post How to Use React Context Effectively.
Here is the ActiveRecord component:
export const ActiveRecordContext = createContext();
export default function ActiveRecord({ recordType, children }) {
const [activeRecord, setActiveRecord] = useState({ recordType });
const [subscribe] = useEventSink();
useEffect(() => {
function onSet(activationVerb, _id) {
let record,
isNew = true;
if (_id) {
record = db.getCollection(recordType).by("_id", _id);
if (record == null) {
throw new Error(`No record exists with id ${_id}`);
}
isNew = false;
} else {
record = { _id: cuid() };
}
setActiveRecord({
activationVerb,
record,
isNew,
recordType,
});
}
const unsubscribes = [
subscribe(`view_${recordType}`, (_id) => onSet("view", _id)),
subscribe(`new_${recordType}`, () => onSet("new")),
subscribe(`edit_${recordType}`, (_id) => onSet("edit", _id)),
subscribe(`cancel_${recordType}`, () => setActiveRecord({ recordType })),
];
return () => unsubscribes.forEach((u) => u());
}, []);
return rc(ActiveRecordContext.Provider, { value: activeRecord }, children);
}
This component listens for several event types. When the component receives a 'view', 'new' or 'edit' event, it will set the activeRecord for this React context to be the 'widget' or 'foo' that these events are referring to. A few helpful pieces of data are also added to the context, such as the recordType (foo or widget in our case), the type of event and whether the user requested a new instance of the recordType.
Components which need to consume any of this data can do so by using the super simple useActiveRecord
hook:
export default function useActiveRecord() {
return useContext(ActiveRecordContext);
}
For instance, the SummaryDetail
component can display the detail (i.e. form) portion of the component when it observes that there is an active record present in the React context.
export default function SummaryDetail({ children }) {
if (children.length !== 2) {
throw new Error("SummaryDetail component requires exactly two children.");
}
const activeRecord = useActiveRecord();
if (activeRecord.record == null) {
return rc(SummaryOnly, null, children[0]);
}
return rc(StyledSummaryDetail, { gutterSize: 5, sizes: [25, 75] }, children);
}
It may bring things together to look at the ActionButton
component. All the buttons visible in the app use this component:
export default function ActionButton(props) {
const { actionType, title, disabled } = props;
const [, publish] = useEventSink();
const { recordType } = useActiveRecord();
function onClick() {
publish(`${actionType}_${recordType}`);
}
return rc(Input, { type: "button", value: title, onClick, disabled });
}
If the user clicks the 'Add Widget' button
rc(ActionButton, { actionType: "new", title: "Add Widget" });
it will raise a new_widget
event that is bounded to components within the EventBoundary
The ActiveRecord component will receive the event and set the requested new widget on the ActiveRecord. As mentioned before, the SummaryDetail component will be informed by the useActiveRecord hook, and will display the detail form for the new widget.
That's probably enough for the post. I'll continue in part 3 by talking about the XState finite state machine used to manage the form interactions.
- Previous: React Loki and XState