DEV Community

loading...
Cover image for React lists without .map

React lists without .map

Mike Talbot
Serial CTO
Updated on ・5 min read

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.

Discussion (21)

Collapse
dikamilo profile image
dikamilo • Edited

React lists without map and still uses map but hidden deep inside after refactoring ;)

You repeat list is still rerendered as whole when you add or remove elements. You can see it in devtools react profiler.

Each RenderItem is rerendered because remove prop change every time, you can fix it by wrapping remove function with useCallback. But RenderItem will still be rerendered in other cases because parent compontent rerenders each time. This can be fixed by using memo on RenderItem and removing index prop from item.type because it changes every time and causes rerenders on children component.

After this, adding new element will render only one RenderItem and removing item will just remove single component.

Cheers

Collapse
miketalbot profile image
Mike Talbot Author

Well you don't use a .map when you make this list was my point ;)

Good point on the remove - I'd do that by using a useCallback if I'd remembered lol:

function App4() {
    const [render, setRender] = useState(items)
    const remove = useCallback(_remove, [])
    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

The index property is important for many things that require sorting etc and while the component will be called when you update the list it then shouldn't be creating a new element if the index stays stable (as I'm inserting at the top of the list that isn't likely)

Collapse
discworldza profile image
DiscworldZA

In theory this is great. It addresses a problem in React I hated for a very long time. However in practise this is too complex I feel. What you did was great and the first step is very important however the second step is too complex. Try having a Junior understand that without needing to explain it. The cons of complexity far outweigh the pros of micro optimisation here. This also turns JSX in React into a templating language which is something React is not made for. Not because it isn't a good idea but because it isn't the convention and therefore requires training. Again this is a great concept but too complex for production I feel.

Collapse
miketalbot profile image
Mike Talbot Author • Edited

I do see your point and I think the code you use on a project should be balanced by the architects controlling it.

Our main app uses this and a <VirtualRepeat> that has the same signature but lets you specify a number of visible items and it's nice because everyone knows them and can swap between them as necessary - however - it does require training and would not work well in many circumstances. Also we use the version that does <Repeat list={blah} item={<SomeItem blahblah={doSomething}/>} /> over the one that uses it as a child.

Collapse
ianwijma profile image
Ian Wijma

Personally I'd prefer using normal JavaScript instead of using a magical wrapper that does exactly the same as a simple .map.

I do build wrappers where it makes sense, but often I'd rather use the following strategy to clean up my code.

Here is a simple example, where I go from a messy implementation:

To a more cleaner implementation:

Collapse
vladislavmurashchenko profile image
VladislavMurashchenko

In my opinion, first is easier to read because of not so many abstractions. I'd prefer to create extra abstractions only when implementation becomes big. Then it becomes simpler to understand which parts of code are worth separate abstractions and which are not.

Collapse
miketalbot profile image
Mike Talbot Author

Agreed, when it's small, totally. I see them bigger than the surrounding component, then I think it should be separated.

Collapse
jwhenry3 profile image
Justin Henry

I love abstractions, but only when it reduces boilerplate and effort to create. The amount of code generated in order to abstract away map makes the effort not that valuable. I do say it's a good learning exercise, but I wouldn't do this in all projects since you introduce more components into the reconciler, and you're needing to spread a bunch of props in order to get the desired result, which means more overhead.

Collapse
miketalbot profile image
Mike Talbot Author

Maybe I'm missing something, but isn't the effect only adding one parent component? All the others would need to be added anyway and have their own props object returned by the createElement(). I've just moved the props setting (and admittedly probably created one more object along the way).

If you wanted to be pure and just make the one props object that every entry in the map needs you could write the output like this:

function Repeat({
    list,
    children,
    item = children.type ? children : <Simple />,
    pass = "item",
    keyFn = getKey
}) {
    return list.map((iterated, index) => {
        return {type: item.type, key: keyFn(iterated), props: {...item.props, [pass]: iterated, index}
    })
}
Enter fullscreen mode Exit fullscreen mode
Collapse
vladislavmurashchenko profile image
VladislavMurashchenko • Edited

In general, doing something like this is quite bold. I respect you for it.

But there are quite many disadvantages in this approach. There are some:

Props like item and index are passed to components implicitly. I personally prefer when everything is explicit. Also in most cases we don't need index and this index can cause unnecessary updates in some cases (for example inserting item to the middle). It can be fixed by get rid of cloning react elements and using render functions.

Also, this cloning React elements with spread operator is quite going too deep into react internal implementation. It would be better to use clone element

I noticed, that getKey relay on references of objects. That means that when reference changes, then key also changes. When key changes, component will be unmounted and mounted again. So it would be better just to have keyFn as required argument.

Repeat can be used only with react elements which doesn't have children inside them.

The last example with so many mutations inside looks really dangerous

Collapse
miketalbot profile image
Mike Talbot Author

Sure, I get your concerns.

A short rebuttal:

  • Repeat can be used on items with inner components, children is just another prop and it works fine

  • Index, sure can go any way on this, I mostly use this on lists that allow sorting, and the sortable HOC requires index so I just leave it in there. You could easily make it work differently by having explicit props for sure.

  • getKey is just a default implementation that works for object items that don't change, you can supply another keyFn and in my main app this would always be v=>v.id || v._id - but this is of course implementation specific.

Collapse
vladislavmurashchenko profile image
VladislavMurashchenko • Edited

About children I just mean, that it is not possible to do something like this:

{items.map(item => (
  <div key={item.name}>
      <span>{item.name}</span>
      <span>{item.age}</span>
  </div>
))}
Enter fullscreen mode Exit fullscreen mode

Because with Repeat we don't have access to item itself. All item based rendering always have to be inside a component and external children of the component must not rely on the item.

In some cases we just don't need a component

Thread Thread
miketalbot profile image
Mike Talbot Author

Right, right sorry. I should have though that through :)

Yeah you can't do that, unless you use a version which takes a function as a child, but then if it's this case... well I'd just probably do the items.map as you point out. My project's version does have support for a function child but really the semantics are then using { } so I don't bother and just use the map.

The real point was when you need to do a sub component because you need hooks in the wrapper. I'd prefer this laid out in the template version (because frequently in our code base we'd swap from <Repeat> to <VirtualRepeat> which has the same signature, but virtualises the elements.

Collapse
thekashey profile image
Anton Korzunov

‘Repeat’ is a good abstraction, which hides some complexity behind it and increases signal/noise ratio.
A perfect example of DSL in the form of Component 👍

Having configurable getKey function with automated defaults is 🤟 as well, because those WeakMap Magic’s are not required if you a real key to use.

in idea to make WeakMap solution a little more testable please take a look at react-uid

Collapse
joesky profile image
Joe_Sky

How about this approach? github.com/leebyron/react-loops

Collapse
miketalbot profile image
Mike Talbot Author • Edited

Thanks, not seen that one!

On first scan, it's using a render function for items - which is fine, but I think it still has cognitive dissonance as you reason with the parameters of the render function and figure out the key on the item. I'm in JSX, I'm about to write some more JSX, I don't need this render function is my personal preference.

My full implementation of this allows for that approach (and multiple functional children if you like!) but if I look at the code where we use our function, it never actually happens. All we ever use is the item={<SomeRenderer/>} because it covers off all of our requirements and the code scans better.

Repeat with auto keying and debug fall back to JSON is 29 lines of code. I wouldn't make an npm package out of that :) I'd write a Dev article LOL!

Collapse
joesky profile image
Joe_Sky

Oh, I basically understand, thanks for your detailed reply.

For the approach without render function, I also have seen this: github.com/AlexGilleran/jsx-contro...

But it has a trouble problem that in TS: the item needs to define a variable separately to match the TS type.

I feel that the approach of react-loops, which maybe has a higher recognition, because it uses the render props pattern commonly used in React. For example, solidjs also uses this <For> design: solidjs.com/docs/latest/api#%3Cfor%3E

After reading your article, I think it's certainly useful. I just feel that at first glance, there is a lot of code, which is a little complex for people who see it for the first time, perhaps it would be better to have a simplified version. Thanks for sharing~

Thread Thread
miketalbot profile image
Mike Talbot Author

I mean, you would have the actual function in a utilities file and it's only a few lines of code. It works fine in TS. Here's an example of Repeat written that way.

Collapse
globalkonvict profile image
Sarthak Dwivedi

Seriously that's cool, and I see you define function after return of component markup. I might try it work. Let's see what I get from my manager, lol.

Collapse
miketalbot profile image
Mike Talbot Author

Yeah that's a "thing", I always do this because I like the main purpose - the body in other words - of a routines to be first. Then utilities it uses next. Doesn't work in TS with linting (because TS hates me!)

Collapse
clivend profile image
clivend

nice exercise but I do not think that the point of react is to write another framework with it :)

Cheers