Mithril

Keys


What are keys?

Keys represent tracked identities. You can add them to element, component, and fragment vnodes via the magic key attribute, and they look something like this when used:

m(".user", {key: user.id}, [/* ... */])

They are useful in a few scenarios:

Key restrictions

Important: For all fragments, their children must contain either exclusively vnodes with key attributes (keyed fragment) or exclusively vnodes without key attributes (unkeyed fragment). Key attributes can only exist on vnodes that support attributes in the first place, namely element, component, and fragment vnodes. Other vnodes, like null, undefined, and strings, can't have attributes of any kind, so they can't have key attributes and thus cannot be used in keyed fragments.

What this translates to is stuff like [m(".foo", {key: 1}), null] and ["foo", m(".bar", {key: 2})] won't work, but [m(".foo", {key: 1}), m(".bar", {key: 2})] and [m(".foo"), null] will. If you forget this, you'll get a very helpful error explaining this.

Linking model data in lists of views

When you're rendering lists, especially editable list, you're often dealing with things like editable TODOs and such. These have state and identities, and you have to give Mithril.js the information it needs to track them.

Suppose we have a simple social media post listing, where you can comment on posts and where you can hide posts for reasons like reporting them.

// `User` and `ComposeWindow` omitted for brevity
function CommentCompose() {
    return {
        view: function(vnode) {
            var post = vnode.attrs.post
            return m(ComposeWindow, {
                placeholder: "Write your comment...",
                submit: function(text) {
                    return Model.addComment(post, text)
                },
            })
        }
    }
}

function Comment() {
    return {
        view: function(vnode) {
            var comment = vnode.attrs.comment
            return m(".comment",
                m(User, {user: comment.user}),
                m(".comment-body", comment.text),
                m("a.comment-hide",
                    {onclick: function() {
                        Model.hideComment(comment).then(m.redraw)
                    }},
                    "I don't like this"
                )
            )
        }
    }
}

function PostCompose() {
    return {
        view: function(vnode) {
            var comment = vnode.attrs.comment
            return m(ComposeWindow, {
                placeholder: "Write your post...",
                submit: Model.createPost,
            })
        }
    }
}

function Post(vnode) {
    var showComments = false
    var commentsFetched = false

    return {
        view: function(vnode) {
            var post = vnode.attrs.post
            var comments = showComments ? Model.getComments(post) : null
            return m(".post",
                m(User, {user: post.user}),
                m(".post-body", post.text),
                m(".post-meta",
                    m("a.post-comment-count",
                        {onclick: function() {
                            if (!showComments && !commentsFetched) {
                                commentsFetched = true
                                Model.fetchComments(post).then(m.redraw)
                            }
                            showComments = !showComments
                        }},
                        post.commentCount, " comment",
                        post.commentCount === 1 ? "" : "s",
                    ),
                    m("a.post-hide",
                        {onclick: function() {
                            Model.hidePost(post).then(m.redraw)
                        }},
                        "I don't like this"
                    )
                ),
                showComments ? m(".post-comments",
                    comments == null
                    ? m(".comment-list-loading", "Loading...")
                    : [
                        m(".comment-list", comments.map(function(comment) {
                            return m(Comment, {comment: comment})
                        })),
                        m(CommentCompose, {post: post}),
                    ]
                ) : null
            )
        }
    }
}

function Feed() {
    Model.fetchPosts().then(m.redraw)
    return {
        view: function() {
            var posts = Model.getPosts()
            return m(".feed",
                m("h1", "Feed"),
                posts == null ? m(".post-list-loading", "Loading...")
                : m(".post-view",
                    m(PostCompose),
                    m(".post-list", posts.map(function(post) {
                        return m(Post, {post: post})
                    }))
                )
            )
        }
    }
}

It encapsulates a lot of functionality as you can tell, but I'd like to zoom into two things:

// In the `Feed` component
m(".post-list", posts.map(function(post) {
    return m(Post, {post: post})
}))

// In the `Post` component
m(".comment-list", comments.map(function(comment) {
    return m(Comment, {comment: comment})
}))

Each of these refers to a subtree with associated state Mithril.js has no idea about. (Mithril.js only knows about vnodes, nothing else.) When you leave those unkeyed, things can and will get weird and unexpected. In this case, try clicking on the "N comments" to show the comments, typing into the comment compose box at the bottom of it, then clicking "I don't like this" on a post above it. Here's a live demo for you to try it out on, complete with a mock model. (Note: if you're on Edge or IE, you may run into issues due to the link's hash length.)

Instead of doing what you would expect, it instead gets really confused and does the wrong thing: it closes the comment list you had open and the post after the one you had the comments open on now just persistently shows "Loading..." even though it thinks it's already loaded the comments. This is because the comments are lazily loaded and they just assume the same comment is passed each time (which sounds relatively sane here), but in this case, it's not. This is because of how Mithril.js patches unkeyed fragments: it patches them one by one iteratively in a very simple fashion. So in this case, the diff might look like this:

And since the component remains the same (it's always Comment), only the attributes change and it's not replaced.

To fix this bug, you simply add a key, so Mithril.js knows to potentially move state around if necessary to fix the issue. Here's a live, working example of everything fixed.

// In the `Feed` component
m(".post-list", posts.map(function(post) {
    return m(Post, {key: post.id, post: post})
}))

// In the `Post` component
m(".comment-list", comments.map(function(comment) {
    return m(Comment, {key: comment.id, comment: comment})
}))

Note that for the comments, while it would technically work without keys in this case, it would similarly break if you were to add anything like nested comments or the ability to edit them, and you'd have to add keys to them.

Keeping collections of animated objects glitch-free

On certain occasions, you might be wanting to animate lists, boxes, and similar. Let's start out with this simple code:

var colors = ["red", "yellow", "blue", "gray"]
var counter = 0

function getColor() {
    var color = colors[counter]
    counter = (counter + 1) % colors.length
    return color
}

function Boxes() {
    var boxes = []

    function add() {
        boxes.push({color: getColor()})
    }

    function remove(box) {
        var index = boxes.indexOf(box)
        boxes.splice(index, 1)
    }

    return {
        view: function() {
            return [
                m("button", {onclick: add}, "Add box, click box to remove"),
                m(".container", boxes.map(function(box, i) {
                    return m(".box",
                        {
                            "data-color": box.color,
                            onclick: function() { remove(box) },
                        },
                        m(".stretch")
                    )
                })),
            ]
        },
    }
}

It looks pretty innocent, but try a live example. In that example, click to make a couple boxes, pick a box, and follow its size. We want the size and spin to be tied to the box (denoted by color) and not the position in the grid. You'll notice that instead, the size ends up jumping suddenly up, but it stays constant with location. This means we need to give them keys.

In this case, giving them unique keys is pretty easy: just create a counter that you increment each time you read it.

 var colors = ["red", "yellow", "blue", "gray"]
 var counter = 0

 function getColor() {
     var color = colors[counter]
     counter = (counter + 1) % colors.length
     return color
 }

 function Boxes() {
     var boxes = []
     var nextKey = 0

     function add() {
-        boxes.push({color: getColor()})
+        var key = nextKey
+        nextKey++
+        boxes.push({key: key, color: getColor()})
     }

     function remove(box) {
         var index = boxes.indexOf(box)
         boxes.splice(index, 1)
     }

     return {
         view: function() {
             return [
                 m("button", {onclick: add}, "Add box, click box to remove"),
                 m(".container", boxes.map(function(box, i) {
                     return m(".box",
                         {
+                            key: box.key,
                             "data-color": box.color,
                             onclick: function() { remove(box) },
                         },
                         m(".stretch")
                     )
                 })),
             ]
         },
     }
 }

Here's a fixed demo for you to play with, to see how it works differently.

Reinitializing views with single-child keyed fragments

When you're dealing with stateful entities in models and such, it's often useful to render model views with keys. Suppose you have this layout:

function Layout() {
    // ...
}

function Person() {
    // ...
}

m.route(rootElem, "/", {
    "/": Home,
    "/person/:id": {
        render: function() {
            return m(Layout,
                m(Person, {id: m.route.param("id")})
            )
        }
    },
    // ...
})

Chances are, your Person component probably looks something like this:

function Person(vnode) {
    var personId = vnode.attrs.id
    var state = "pending"
    var person, error

    m.request("/api/person/:id", {params: {id: personId}}).then(
        function(p) { person = p; state = "ready" },
        function(e) { error = e; state = "error" }
    )

    return {
        view: function() {
            if (state === "pending") return m(LoadingIcon)
            if (state === "error") {
                return error.code === 404
                    ? m(".person-missing", "Person not found.")
                    : m(".person-error",
                        "An error occurred. Please try again later"
                    )
            }
            return m(".person",
                m(m.route.Link,
                    {
                        class: "person-edit",
                        href: "/person/:id/edit",
                        params: {id: personId},
                    },
                    "Edit"
                ),
                m(".person-name", "Name: ", person.name),
                // ...
            )
        }
    }
}

Say, you added a way to link to other people from this component, like maybe adding a "manager" field.

function Person(vnode) {
    // ...

    return {
        view: function() {
            // ...
            return m(".person",
                m(m.route.Link,
                    {
                        class: "person-edit",
                        href: "/person/:id/edit",
                        params: {id: personId},
                    },
                    "Edit"
                ),
                m(".person-name", person.name),
                // ...
                m(".manager",
                    "Manager: ",
                    m(m.route.Link,
                        {
                            href: "/person/:id",
                            params: {id: person.manager.id}
                        },
                        person.manager.name
                    )
                ),
                // ...
            )
        }
    }
}

Assuming the person's ID was 1 and the manager's ID was 2, you'd switch from /person/1 to /person/2, remaining on the same route. But since you used the route resolver render method, the tree was retained and you just changed from m(Layout, m(Person, {id: "1"})) to m(Layout, m(Person, {id: "2"})). In this, the Person didn't change, and so it doesn't reinitialize the component. But for our case, this is bad, because it means the new user isn't being fetched. This is where keys come in handy. We could change the route resolver to this to fix it:

m.route(rootElem, "/", {
    "/": Home,
    "/person/:id": {
        render: function() {
            return m(Layout,
                // Wrap it in an array in case we add other elements later on.
                // Remember: fragments must contain either only keyed children
                // or no keyed children.
                [m(Person,
                    {id: m.route.param("id"), key: m.route.param("id")}
                )]
            )
        }
    },
    // ...
})

Common gotchas

There's several common gotchas that people run into with keys. Here's some of them, to help you understand why they don't work.

Wrapping keyed elements

These two snippets don't work the same way:

users.map(function(user) {
    return m(".wrapper", [
        m(User, {user: user, key: user.id})
    ])
})

users.map(function(user) {
    return m(".wrapper", {key: user.id}, [
        m(User, {user: user})
    ])
})

The first binds the key to the User component, but the outer fragment created by users.map(...) is entirely unkeyed. Wrapping a keyed element this way doesn't work, and the result could be anything ranging from extra requests each time the list is changed to inner form inputs losing their state. The resulting behavior would similar to the post list's broken example, but without the issue of state corruption.

The second binds it to the .wrapper element, ensuring the outer fragment is keyed. This does what you likely wanted to do all along, and removing a user won't pose any issues with the state of other user instances.

Putting keys inside the component

Suppose, in the person example, you did this instead:

// AVOID
function Person(vnode) {
    var personId = vnode.attrs.id
    // ...

    return {
        view: function() {
            return m.fragment({key: personId},
                // what you previously had in the view
            )
        }
    }
}

This won't work, because the key doesn't apply to the component as a whole. It just applies to the view, and so you aren't re-fetching the data like you were hoping for.

Prefer the solution used there, putting the key in the vnode using the component rather than inside the component itself.

// PREFER
return [m(Person,
    {id: m.route.param("id"), key: m.route.param("id")}
)]

Keying elements unnecessarily

It's a common misconception that keys are themselves identities. Mithril.js enforces for all fragments that their children must either all have keys or all lack keys, and will throw an error if you forget this. Suppose you have this layout:

m(".page",
    m(".header", {key: "header"}),
    m(".body"),
    m(".footer"),
)

This obviously will throw, as .header has a key and .body and .footer both lack keys. But here's the thing: you don't need keys for this. If you find yourself using keys for things like this, the solution isn't to add keys, but to remove them. Only add them if you really, really need them. Yes, the underlying DOM nodes have identities, but Mithril.js doesn't need to track those identities to correctly patch them. It practically never does. Only with lists where each entry has some sort of associated state Mithril.js doesn't itself track, whether it be in a model, in a component, or in the DOM itself, do you need keys.

One last thing: avoid static keys. They're always unnecessary. If you're not computing your key attribute, you're probably doing something wrong.

Note that if you really need a single keyed element in isolation, use a single-child keyed fragment. It's just an array with a single child that's a keyed element, like [m("div", {key: foo})].

Mixing key types

Keys are read as object property names. This means 1 and "1" are treated identically. If you want to keep your hair, don't mix key types if you can help it. If you do, you could wind up with duplicate keys and unexpected behavior.

// AVOID
var things = [
    {id: "1", name: "Book"},
    {id: 1, name: "Cup"},
]

If you absolutely must and you have no control over this, use a prefix denoting its type so they remain distinct.

things.map(function(thing) {
    return m(".thing",
        {key: (typeof thing.id) + ":" + thing.id},
        // ...
    )
})
Hiding keyed elements with holes

Holes like null, undefined, and booleans are considered unkeyed vnodes, so code like this won't work:

// AVOID
things.map(function(thing) {
    return shouldShowThing(thing)
        ? m(Thing, {key: thing.id, thing: thing})
        : null
})

Instead, filter the list before returning it, and Mithril.js will do the right thing. Most of the time, Array.prototype.filter is precisely what you need and you should definitely try it out.

// PREFER
things
    .filter(function(thing) { return shouldShowThing(thing) })
    .map(function(thing) {
        return m(Thing, {key: thing.id, thing: thing})
    })

Duplicate keys

Keys for fragment items must be unique, or otherwise, it's unclear and ambiguous what key is supposed to go where. You may also have issues with elements not moving around like they're supposed to.

// AVOID
var things = [
    {id: "1", name: "Book"},
    {id: "1", name: "Cup"},
]

Mithril.js uses an empty object to map keys to indices to know how to properly patch keyed fragments. When you have a duplicate key, it's no longer clear where that element moved to, and so Mithril.js will break in that circumstance and do unexpected things on update, especially if the list changed. Distinct keys are required for Mithril.js to properly connect old to new nodes, so you must choose something locally unique to use as a key.


License: MIT. © Leo Horie.