TLDR
If you are curious about the full-working example, it is here.
Some Background
On one of my previous projects, the team that I was working on, was occupied with the rewriting of an existing React web platform. One of the key goals was to incrementally implement xState as a state manager instead of Redux. The project was poorly documented and maintained, so we were optimistic that the state machines would give us some clarity and sustainability.
I was completely unfamiliar with xState and state machines in general, and it was challenging. I've reworked quite many Redux reducers into state machines, but meanwhile the team had to take care of the incoming new feature requests. One of them was to enable filtering on a vast list of items. We didn't have the resources for filtering api on the BE and it was clear that the new functionality would completely rely on the FE.
After a quick research, Fuse.js seemed like a good fit:
With Fuse.js, you don’t need to setup a dedicated backend just to handle search.
Therefore, here are my thoughts on the whole process:
Machine preparation
The domain of the project was related to clinical research, but this specific list was consisting of all platform clients/us. To mimic the use case, we need a machine
that loads the user items on initial state
(the moment that the page is opened) and after that sets them in the context
.
const machine = createMachine({
context: { users: [] },
id: "userItemsMachine",
initial: "fetching",
states: {
fetching: {
invoke: {
src: "fetchUsers",
onDone: {
actions: ["setUsers"],
target: "idle",
},
},
},
idle: {},
},
});
After we have the users fetching logic, we can continue with a component that will display them. We interpret
the machine with the useMacahine()
hook and now the machine
context
is available in the React component.
export function UsersScreen() {
const [state, send] = useMachine(machine);
return (
<div>
{state.matches("fetching") ? (
<div>...loading</div>
) : (
state.context.users.map((user) => {
return <div key={user.id}>{user.name}</div>;
})
)}
</div>
);
}
Showing a loading indicator while the data items are being fetched is one of the things that state machines are doing with ease.
After we have the users data set and displayed, it is time to start implementing the actual filtering.
Fuse.js is relatively straightforward to use, but as I mentioned, the team has just started using xState. We weren't quite sure how to combine a third party library with the machines. The inevitable happened and fuse.js has landed on the React component.
First iteration (not great, not terrible)
I already had the feeling that we were not going in the best direction. Since Don't use useState
was our rule of thumb and we needed a place to store our filtering criteria, I tried involving the state machine a bit more and introduced a filterString
into the machine
context
.
const machine = createMachine({
context: { users: [], filterString: "" },
id: "userItemsMachine",
initial: "fetching",
states: {
fetching: {
invoke: {
src: "fetchUsers",
onDone: {
actions: ["setUsers"],
target: "idle",
},
},
},
idle: {on: {"FILTER_USERS": {
actions: ['setFilterString']
}}},
},
});
The FILTER_USERS
event
appears in order to handle the filterString
manipulation. This means that when the machine is idle
(not fetching data), we can filter the displayed items.
Going further, we need to introduce a filter input that will glue the state machine with the search library.
Every time the filter input value changes, a FILTER_USER
event
is sent to the machine. The event
takes care of updating the context
and we can read it from state.context.filterString
in the view. Now we have a controlled component which is ready to filter some data.
// Creating the Fuse instance outside of the component
// prevents reinitialisation on each rerender, but
// state.context.users cannot be used as initial `fuse`
// collection
const fuse = new Fuse([], { keys: ["name"] });
export function UsersScreen() {
const [state, send] = useMachine(machine);
useEffect(() => {
// Additionally, setCollection should be called from within
// the useEffect() to synchronise the collection value.
fuse.setCollection(state.context.users);
}, [state.context.users]);
const filteredUsers = fuse.search(state.context.searchString).map((user) => {
return user.item;
});
return (
<div>
<input
value={state.context.filterString}
type="search"
onChange={(event) => {
send({ type: "FILTER_USER", filterString: event.target.value });
}}
/>
{state.matches("fetching") ? (
<div>...loading</div>
) : (
// When passing an empty string to `fuse.search()`,
// we will be returned an empty array instead of the
// whole list. This means we cannot rely exclusively
// on the filtered items.
(state.context.filterString === ""
? state.context.users
: filteredUsers
).map((user) => {
return <div key={user.id}>{user.name}</div>;
})
)}
</div>
);
}
The integration felt a bit clumsy and didn't match my test much. Calling fuse.setCollection
from within the component didn't stand out as the best integration with state machines, but it was still a reasonable pattern. The warning sign for me was that our view relies on two different data sets from two different libraries to display the list of users.
Anyway, the time was short, the code freeze date was approaching, and the functionality as it is was released.
The refactor
The next sprint started with exploring the possibilities of xState and how we can get rid of the red flags that were shipped with the previous release.
While going through the xState documentation, noticed the invoked callback
service. Then I stumbled upon this post and was sure that this was the missing piece.
The invoke
should be happening after that machine is done with the user items load. This happens to be the idle
state.
idle: {
invoke: {
id: "filterCallback",
src: (context) => (sendBack) => {
const fuse = new Fuse(context.users, {
keys: ["name"]
});
}
}
}
Finally, the Fuse instance has access to the context.users
and its collection is properly set on initialization.
Following, we should find a way to communicate with fuse
. Luckily, the invoked callback
is capable of listening for events
coming from it's parent. This happens via the onReceive
argument.
idle: {
invoke: {
id: "filterCallback",
src: (context) => (sendBack, onReceive) => {
const fuse = new Fuse(context.users, {
keys: ["name"]
});
onReceive((event) => {
switch (event.type) {
case "FILTER":
// Fuse.js is filtering based on the filter string from the user input.
const filteredUsers = fuse
.search(event.searchString)
.map(({ item }) => item);
// The `SET_FILTERED_USERS` event is sent back to the parent
// where the filtered users are set into the context
sendBack({
type: "SET_FILTERED_USERS",
users: filteredUsers
});
break;
}
});
}
},
on: {
FILTER_USERS: {
actions: [
"setFilterString",
// The `send` - `to` combination enables
// the communication with the invoked callback service.
// The `FILTER` event will be handled by the `onReceive` method
// and manipulate the users collection accordingly.
send(
(context) => {
return {
type: "FILTER",
searchString: context.filterString
};
},
{ to: "filterCallback" }
)
]
},
SET_FILTERED_USERS: {
actions: "setFilteredUsers"
}
}
}
By embracing the invoke callback
technique, we fixed a couple of things that were in our concerns from the previous integration:
- Fuse integration is moved from React to xState.
- Both filtered and unfiltered user collections are exiting into the
machine
context
.
It is time to get rid of the state.context.filterString === ""
condition that is still part of our view. By doing so, we have a chance to let our view rely on a single source of data that is controlled by the machine
.
FILTER_USERS: [
{
// Setting a `guard` to check the `filterString` value,
// is giving us better control over the flow.
cond: (context, event) => {
return event.filterString.length !== 0;
},
actions: [
"setFilterString",
send(
(context) => {
return {
type: "FILTER",
searchString: context.filterString
};
},
{ to: "filterCallback" }
)
]
},
{
actions: [
"setFilterString",
// When we hit an empty string, we send the original users list
// as a payload to the `RESTORE` event in the `onReceive` method.
send(
(context) => {
return {
type: "RESTORE",
users: context.users
};
},
{ to: "filterCallback" }
)
]
}
];
The new RESTORE
event
simply overrides the filteredUsers
array from the machine
context
with the users
one. By doing so, we guarantee that all initially fetched users will be available.
case "RESTORE":
sendBack({
type: "SET_FILTERED_USERS",
users: event.users
});
In this way the only data items set the we need to consume in the React view is the filteredUsers
.
state.matches("fetching") ? (
<div>...loading</div>
) : (
state.context.filteredUsers.map((user) => {
return <div key={user.id}>{user.name}</div>;
})
);
Opinionated conclusion
It might look like a lot of complexity was introduced with the rewriting, but this way we fully embrace the idea of xState being framework-agnostic. By separating view and logic, better control over the application is gained, and that's where we can take advantage of state machines' explicitness.
Top comments (0)