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))
}
}
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>
)
}
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))
}
}
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))
}
}
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"/>
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 }}
/>
)
})
}
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>
}
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
}
}
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 }}
/>
)
})
}
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 }}
/>
)
})
}
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
}
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.
Top comments (27)
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 becauseremove
prop change every time, you can fix it by wrappingremove
function withuseCallback
. ButRenderItem
will still be rerendered in other cases because parent compontent rerenders each time. This can be fixed by usingmemo
onRenderItem
and removingindex
prop fromitem.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
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 auseCallback
if I'd remembered lol: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)I noticed you set the length of the array. What's the time complexity of doing that to truncate an array? Is it O(1) and the extra elements get garbage collected at a later time?
Thanks!
A very good question, I researched other people's array truncation jsperfs and tried a couple of my own, it ended up being the fastest way at the time to truncate. I'd expect the V8 engine considers the out of range elements to be unreachable and available for GC but possibly doesn't consider the pointer array to be resized - it's a guess though, I suppose you'd have to look at the implementation detail in the engine to be sure and I didn't do that.
One more thought on this I had was if the garbage collector needs to iterate over each extra element to remove it from memory or could it somehow do a bulk delete of the extra elements? If it has to iterate then it would be the same as array.slice with O(n - m) where n = previous length, m = new shorter length. But if it can do a bulk delete it would be good. Not sure though as I don't know this much about the GC, do you have more info or could point me in the right direction?
appreciate the response thanks!
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.
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.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:
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.
Agreed, when it's small, totally. I see them bigger than the surrounding component, then I think it should be separated.
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.
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:
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
andindex
are passed to components implicitly. I personally prefer when everything is explicit. Also in most cases we don't needindex
and thisindex
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 havekeyFn
as required argument.Repeat
can be used only with react elements which doesn't havechildren
inside them.The last example with so many mutations inside looks really dangerous
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.About children I just mean, that it is not possible to do something like this:
Because with
Repeat
we don't have access to item itself. Allitem
based rendering always have to be inside a component and external children of the component must not rely on theitem
.In some cases we just don't need a component
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 themap
.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.‘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.
How about this approach? github.com/leebyron/react-loops
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!
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 therender props pattern
commonly used in React. For example,solidjs
also uses this<For>
design: solidjs.com/docs/latest/api#%3Cfor%3EAfter 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~
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.
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.
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!)
Make things complicated.
.map
is clear. If your list item JSX has many, you should consider extract an item component.:) Your choice, but if you look at the next part which takes the principle to virtualised windows and lists then (as that article mentions) it becomes a lot more than sugar.