Client side filtering with xState and Fuse.js

gtodorov

Georgi Todorov

Posted on December 12, 2022

Client side filtering with xState and Fuse.js

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: {},
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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']
    }}},
  },
});
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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"]
      });
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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"
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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" }
      )
    ]
  }
];
Enter fullscreen mode Exit fullscreen mode

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
  });
Enter fullscreen mode Exit fullscreen mode

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>;
  })
);
Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
gtodorov
Georgi Todorov

Posted on December 12, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related