React lists without .map

Mike Talbot ⭐ - Aug 4 '21 - - Dev Community

When we are rendering data in React we often grab an array and do a .map() to write out our interface. The inclusion of instructional statements in the JSX markup can start to get unwieldy however and I like to replace too many code constructs with components instead.

I'll show you the component I use and as we examine it, we'll learn how to manipulate JSX Elements at the same time.

The problem

Take this broken code, it not only has a bug that rears its head when we modify the list, it's also complicated:


function App1() {
    const [render, setRender] = useState(items)
    return (
        <Box>
            <List className="App">
                {/* WRITE THE LIST TO THE UI */}
                {render.map((item, index) => {
                    const [on, setOn] = useState(item.on)
                    return (
                        <ListItem key={index + item.name}>
                            <ListItemText primary={item.name} />
                            <ListItemSecondaryAction>
                                <Box display="flex">
                                    <Box>
                                        <Switch
                                            checked={on}
                                            onChange={() => setOn((on) => !on)}
                                        />
                                    </Box>
                                    <Box ml={1}>
                                        <IconButton
                                            color="secondary"
                                            onClick={() => remove(item)}
                                        >
                                            <MdClear />
                                        </IconButton>
                                    </Box>
                                </Box>
                            </ListItemSecondaryAction>
                        </ListItem>
                    )
                })}
            </List>
            <Button variant="contained" color="primary" onClick={add}>
                Add
            </Button>
        </Box>
    )

    function add() {
        setRender((items) => [
            { name: "Made up at " + Date.now(), on: false },
            ...items
        ])
    }

    function remove(item) {
        setRender((items) => items.filter((i) => i !== item))
    }
}
Enter fullscreen mode Exit fullscreen mode

We've got a list of items and we want to render them and manipulate each one. This will render fine the first time, but click on the Add or remove icon and it will crash. We aren't using a component in the map and so we can't use hooks. Try it:

I see a lot of ugly code like this which may well work if there aren't hooks involved, but I don't like it one bit.

In any case, to make our example work we would first extract out the item to be rendered, which will make our code easier to reason with and create a boundary for the React Hooks so that they no longer fail.


function RenderItem({ item, remove }) {
    const [on, setOn] = useState(item.on)
    return (
        <ListItem>
            <ListItemText primary={item.name} />
            <ListItemSecondaryAction>
                <Box display="flex">
                    <Box>
                        <Switch
                            checked={on}
                            onChange={() => setOn((on) => !on)}
                        />
                    </Box>
                    <Box ml={1}>
                        <IconButton
                            color="secondary"
                            onClick={() => remove(item)}
                        >
                            <MdClear />
                        </IconButton>
                    </Box>
                </Box>
            </ListItemSecondaryAction>
        </ListItem>
    )
}
Enter fullscreen mode Exit fullscreen mode

Once we have this we update our app to use it:

function App2() {
    const [render, setRender] = useState(items)
    return (
        <Box>
            <List className="App">
                {render.map((item, index) => (
                    <RenderItem
                        remove={remove}
                        key={item.name + index}
                        item={item}
                    />
                ))}
            </List>
            <Button variant="contained" color="primary" onClick={add}>
                Add
            </Button>
        </Box>
    )

    function add() {
        setRender((items) => [
            { name: "Made up at " + Date.now(), on: false },
            ...items
        ])
    }

    function remove(item) {
        setRender((items) => items.filter((i) => i !== item))
    }
}

Enter fullscreen mode Exit fullscreen mode

This is much better, but it's still a bit of a mess, our key structure is going to create re-renders we don't need when items are added or removed and we still have to take the cognitive load of the { and the render.map etc.

It would be nicer to write it like this:

function App4() {
    const [render, setRender] = useState(items)
    return (
        <Box>
            <List className="App">
                <Repeat list={render}>
                    <RenderItem remove={remove} />
                </Repeat>
            </List>
            <Button variant="contained" color="primary" onClick={add}>
                Add
            </Button>
        </Box>
    )

    function add() {
        setRender((items) => [
            { name: "Made up at " + Date.now(), on: false },
            ...items
        ])
    }

    function remove(item) {
        setRender((items) => items.filter((i) => i !== item))
    }
}
Enter fullscreen mode Exit fullscreen mode

This would need to have the RenderItem repeated for each item in the list.

A solution

Ok so let's write a Repeat component that does what we like.

The first thing to know is that when we write const something = <RenderItem remove={remove}/> we get back an object that looks like: {type: RenderItem, props: {remove: remove}}. With this information we can render that item with additional props like this:


    const template = <RenderItem remove={remove}/>
    return <template.type {...template.props} something="else"/>

Enter fullscreen mode Exit fullscreen mode

Let's use that to make a Repeat component:

function Repeat({
    list,
    children,
    item = children.type ? children : undefined,
}) {
    if(!item) return
    return list.map((iterated, index) => {
        return (
            <item.type
                {...{ ...item.props, item: iterated, index }}
            />
        )
    })
}
Enter fullscreen mode Exit fullscreen mode

We make use an item prop for the thing to render and default it to the children of the Repeat component. Then we run over this list. For each item in the list we append an index and an item prop based on the parameters passed by the .map()

This is fine, but perhaps it would be nicer to return "something" if we don't specify children or item. We can do that by making a Simple component and use that as the fall back rather than undefined.

function Simple({ item }) {
    return <div>{typeof item === "object" ? JSON.stringify(item) : item}</div>
}
Enter fullscreen mode Exit fullscreen mode

This function does have a problem, it's not specifying a key. So firstly lets create a default key function that uses a WeakMap to create a unique key for list items.


const keys = new WeakMap()
let repeatId = 0
function getKey(item) {
    if (typeof item === "object") {
        const key = keys.get(item) ?? repeatId++
        keys.set(item, key)
        return key
    } else {
        return item
    }
}
Enter fullscreen mode Exit fullscreen mode

This function creates a unique numeric key for each object type of item it encounters, otherwise it returns the item. We can enhance our Repeat function to take a key function to extract a key from the current item, or use this generic one as a default:

function Repeat({
    list,
    children,
    item = children.type ? children : <Simple />,
    keyFn = getKey
}) {
    return list.map((iterated, index) => {
        return (
            <item.type
                key={keyFn(iterated)}
                {...{ ...item.props, item: iterated, index }}
            />
        )
    })
}
Enter fullscreen mode Exit fullscreen mode

Maybe the final step is to allow some other prop apart from "item" to be used for the inner component. That's pretty easy...

function Repeat({
    list,
    children,
    item = children.type ? children : <Simple />,
    pass = "item", // Take the name for the prop
    keyFn = getKey
}) {
    return list.map((iterated, index) => {
        return (
            <item.type
                key={keyFn(iterated)}
                // Use the passed in name
                {...{ ...item.props, [pass]: iterated, index }}
            />
        )
    })
}
Enter fullscreen mode Exit fullscreen mode

The end result is fully functional and a lot easier to reason with than versions that use .map() - at least to my mind :)

Here's all the code from the article.

-

Addendum:

In answer to a couple of the points made in the comments, I thought I'd just optimise Repeat to use less memory and allocations that the .map() version. I also removed the .map() inside so I'm not "hiding" it :) TBH I don't think this is necessary as there need to be more changes to the application logic if the lists are super long and Garbage Collection is pretty powerful anyhow (lets face it those .maps are copying arrays that this new version isn't).

function Repeat({
    list,
    children,
    item = children.type ? children : <Simple />,
    pass = "item",
    keyFn = getKey
}) {
    const [keys] = useState({})
    const [output] = useState([])
    let index = 0
    for (let iterated of list) {
        let key = keyFn(iterated) ?? index
        output[index] = keys[key] = keys[key] || {
            ...item,
            key,
            props: { ...item.props, [pass]: iterated }
        }
        output[index].props.index = index
        index++
    }
    output.length = index
    return output
}
Enter fullscreen mode Exit fullscreen mode

One complaint about this version could be that it holds structures for list items that are no longer seen while the component is mounted. Removing those would be possible but seems like overkill and if you're that worried about allocations then it's a trade off. The natural .map() is creating arrays and sub items every time in any case - so now if that's an issue, this version is a pattern to avoid it.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player