Managing Indexed Collections with useMutableSource
6 min readWith 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}910function 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.
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.length11 items.current.push({ value })12 }13 return index14 }, [])15 return {16 useItem,17 }18}
items
are used as a container for the items we want to subscribeversion
is used increateMutableSource
in order to prevent tearing updates in concurrent mode.- We then create our
mutableSource
which in our case isnull
since we are using this for a different purpose. Please note we needuseConstant
here so the returned value fromcreateMutableSource
does not change between renders. - Finally, we return a
useItem
hook which allows us access to theindex
based on a provided value. Note we are usinguseConstant
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)1718 let index = items.current.findIndex((item) => item.value === value)1920 if (index === -1) {21 index = items.current.length22 items.current.push({ value })23 }2425 return index26 }, [])2728 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 useversion
here as well. - Our
subscribe
function will store the providedcallbacks
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)1920 let index = items.current.findIndex((item) => item.value === value)2122 if (index === -1) {23 index = items.current.length24 items.current.push({ value })25 }2627 React.useLayoutEffect(() => {28 return () => {29 forceUpdate()30 shouldCollectItems.current = true31 }32 }, [])3334 React.useLayoutEffect(() => {35 if (shouldCollectItems.current === false) {36 forceUpdate()37 shouldCollectItems.current = true38 }39 })4041 return index42 }, [])4344 React.useLayoutEffect(() => {45 if (shouldCollectItems.current === true) {46 version.current += 147 items.current = []48 callbacks.current.forEach((callback) => callback())49 shouldCollectItems.current = null50 forceUpdate()51 } else {52 shouldCollectItems.current = false53 }54 })5556 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.
- jsx
- components
- hooks
- react