souporserious

Managing Indexed Collections with useMutableSource

6 min read

With new low-level hooks for controlling state updates, we can simplify component prop APIs.

This article covers the new useMutableSource hook that was just released on 3/11 and is only available under the experimental version of React. I want to strongly note that aside from useMutableSource some of these are not best practices in React and I’m using this as a workaround for the lack of a primitive to manage indexes. This is an advanced use case that should be used to study more than practice.

In most cases, managing indexes in React is rarely a problem. However, as you begin to abstract components into your library or possibly a shared system, exposing these implementation details can hinder how consumers use our components.

Using the HTML select element as an example, how might we build a similar component that keeps track of its indexes and allows for full composition?

The browser handles highlighting, keyboard interactions, grouping, and more for free without exposing any details of indexes. In an ideal world, we can easily build our own robust, accessible components like these in React. These wouldn’t just be for the web either. Imagine a React Native app that uses a game controller, or a TV app that moves through large collections of items.

useMutableSource to the rescue

At the time of this writing, the useMutableSource hook is only available in the experimental version of React. As there is no official documentation yet, please let me know if you find anything that should be updated.

React recently added a new hook called useMutableSource. This hook is responsible for scheduling concurrent-mode safe state updates. While it was created for libraries like redux, we’ll be using it a bit differently today to manage indexes for a Select component.

This is an oversimplified example of the API we would like to achieve:

1function Option({ children, value, useItem }) {
2 const index = useItem({ value })
3 return (
4 <div>
5 {index} {children}
6 </div>
7 )
8}
9
10function Select({ children }) {
11 const useItem = useItemList()
12 return (
13 <ul>
14 {React.Children.map(children, (child) =>
15 React.cloneElement(child, { useItem })
16 )}
17 </ul>
18 )
19}

I’ve created a Codesandbox with an example if you’d like to follow along. Please review the RFC for current documentation on how this new hook works.

Edit useItemList (useMutableSource)

useItemList hook

First, we’ll create a hook called useItemList. This hook will be responsible for collecting a value and managing its position in the React tree. We will also be using two other hooks to help coordinate updates use-constant and use-force-update.

1function useItemList() {
2 const items = React.useRef([])
3 const version = React.useRef(0)
4 const mutableSource = useConstant(() =>
5 React.createMutableSource(null, () => version.current)
6 )
7 const useItem = useConstant(({ value }) => {
8 let index = items.current.findIndex((item) => item.value === value)
9 if (index === -1) {
10 index = items.current.length
11 items.current.push({ value })
12 }
13 return index
14 }, [])
15 return {
16 useItem,
17 }
18}
  • items are used as a container for the items we want to subscribe
  • version is used in createMutableSource in order to prevent tearing updates in concurrent mode.
  • We then create our mutableSource which in our case is null since we are using this for a different purpose. Please note we need useConstant here so the returned value from createMutableSource does not change between renders.
  • Finally, we return a useItem hook which allows us access to the index based on a provided value. Note we are using useConstant here as well since hooks should never change between renders.

Consuming mutableSource

Now we need to connect our mutableSource using useMutableSource:

1function useItemList() {
2 const items = React.useRef([])
3 const version = React.useRef(0)
4 const callbacks = React.useRef([])
5 const mutableSource = useConstant(() =>
6 React.createMutableSource(null, () => version.current)
7 )
8 const useItem = useConstant(({ value }) => {
9 const getSnapshot = React.useCallback(() => version.current, [])
10 const subscribe = React.useCallback((_, callback) => {
11 callbacks.current.push(callback)
12 return () => {
13 callbacks.current = callbacks.current.filter((c) => c !== callback)
14 }
15 }, [])
16 React.useMutableSource(mutableSource, getSnapshot, subscribe)
17
18 let index = items.current.findIndex((item) => item.value === value)
19
20 if (index === -1) {
21 index = items.current.length
22 items.current.push({ value })
23 }
24
25 return index
26 }, [])
27
28 return {
29 useItem,
30 }
31}

The useMutableSource hook requires 3 arguments, the source we created earlier, a getSnaphot function and a subscribe function.

  • The getSnaphot function will be responsible for re-rendering our hook when the returned value changes. In this case, we use version here as well.
  • Our subscribe function will store the provided callbacks in a ref so we can access them later and let React know to schedule an update for our component.

Updating our component when indexes change

This is the last portion to coordinating updates to our indexes safely and where useMutableSource shines:

1function useItemList() {
2 const forceUpdate = useForceUpdate()
3 const items = React.useRef([])
4 const shouldCollectItems = React.useRef(null)
5 const version = React.useRef(0)
6 const callbacks = React.useRef([])
7 const mutableSource = useConstant(() =>
8 React.createMutableSource(null, () => version.current)
9 )
10 const useItem = useConstant(({ value }) => {
11 const getSnapshot = React.useCallback(() => version.current, [])
12 const subscribe = React.useCallback((_, callback) => {
13 callbacks.current.push(callback)
14 return () => {
15 callbacks.current = callbacks.current.filter((c) => c !== callback)
16 }
17 }, [])
18 React.useMutableSource(mutableSource, getSnapshot, subscribe)
19
20 let index = items.current.findIndex((item) => item.value === value)
21
22 if (index === -1) {
23 index = items.current.length
24 items.current.push({ value })
25 }
26
27 React.useLayoutEffect(() => {
28 return () => {
29 forceUpdate()
30 shouldCollectItems.current = true
31 }
32 }, [])
33
34 React.useLayoutEffect(() => {
35 if (shouldCollectItems.current === false) {
36 forceUpdate()
37 shouldCollectItems.current = true
38 }
39 })
40
41 return index
42 }, [])
43
44 React.useLayoutEffect(() => {
45 if (shouldCollectItems.current === true) {
46 version.current += 1
47 items.current = []
48 callbacks.current.forEach((callback) => callback())
49 shouldCollectItems.current = null
50 forceUpdate()
51 } else {
52 shouldCollectItems.current = false
53 }
54 })
55
56 return {
57 useItem,
58 }
59}

We set up a shouldCollectItems flag to force update our parent useItemList hook when useItem is updated for any reason and falls out of sync. In the parent hook’s useLayoutEffect we check for when that flag is set, and if it is, we increment our version and call each callback we stored earlier in the subscribe function. This will signal React to re-render all of our useItem hooks in the order which they appear in the tree regardless of the order we stored the callbacks. Now we have properly indexed items on every render before it’s painted to the screen 🎉.

Summary

Although this isn’t what the useMutableSource hook was necessarily created for, I hope this gave you some ideas on how it can be used to manage concurrent safe updates for nuanced problems like managing indexes.

Hopefully, one day, we will have a better primitive for managing these indexes in React, for now, I’ve created an official useItemList hook to help that can hopefully be updated in the future without breaking changes. This version uses a previous technique that I will update once useMutableSource is out of the experimental phase.

Huge thanks to @AndaristRake and diegohaz for their ongoing discussions about this and thanks to brian_d_vaughn and everyone else for their work on the useMutableSource hook.

Updated:
  • jsx
  • components
  • hooks
  • react
Previous post
Lazy Loading Polyfills
Next post
Easier Layouts with Stacks
© 2022 Travis Arnold