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.