Client side filtering with xState and Fuse.js
Tags: react, webdev, xstate, fusejs
Published: 2022-12-12 10:10:08

### TLDR
If you are curious about the full-working example, it is [here](https://github.com/g-todorov/client-side-filtering-example).

### 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](https://xstate.js.org/) 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](https://fusejs.io/) 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`.

```javascript
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.

```javascript
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`.

```javascript
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.

```javascript
// 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](https://dev.to/mattpocockuk/xstate-why-i-love-invoked-callbacks-2f6i) 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.

```javascript
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.

```javascript
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`.

```javascript
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.

```javascript
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`.

```javascript
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.