Caleb Porzio

Reactive switchboard: making a big, slow Vue/Alpine page "blazingly" fast

Mar 2023

Heads up: I'm going to use Vue/Alpine lingo in this article, but I think this pattern applies to lots of different tools.

I encountered an app the other day with a giant table of thousands of rows. Each row was an individual Alpine component, and you could "activate" one by clicking on it (which would highlight it using CSS). If you clicked a different row, the previous one would become "deactivated" and the new one would "activate".

The problem was: activating a single row took almost a full second.

This kind of performance made the whole thing feel almost unusable, especially if you're using the keyboard to navigate cells.

So, I set the page to load ten thousand rows and went on a quest to make it as performant as possible. After a while, I came up with a neat little pattern that made the page update instantly. I call it: The Switchboard Pattern

Let me show you...

For this demo, I'm not going to use Alpine. Instead, I'm going to use a few reactive utilities offered by Vue that Alpine uses under the hood. If you're not familiar with "watchEffect" and "ref", you should be able to intuit how they work just by following along and reading the code snippets. If not, docs here.

Ok, given our table with 10k rows on it. Here's some simple code you could write to highlight the currently active row when the page loads:

let activeRow = 12

document.querySelectorAll('tr').forEach((row) => {
    if (row.id === activeRow) {
        row.classList.add('active')
    } else {
        row.classList.remove('active')
    }
})

Now, we can add a click listener to set the new active row when a different one is clicked:

let activeRow = 12

document.querySelectorAll('tr').forEach((row) => {
    row.addEventListener('click', () => {
        activeRow = row.id
    })

    if (row.id === activeRow) {
        row.classList.add('active')
    } else {
        row.classList.remove('active')
    }
})

The problem with the above code is that when a row is clicked, the activeRow value will change, but none of the rows will visually update.

Here's how we can use "reactivity" to trigger all the rows to update themselves when the activeRow value changes:

import { ref, watchEffect } from 'vue'

let activeRow = ref(12)

document.querySelectorAll('tr').forEach((row) => {
    row.addEventListener('click', () => {
        activeRow.value = row.id
    })

    watchEffect(() => {
        if (row.id === activeRow.value) {
            row.classList.add('active')
        } else {
            row.classList.remove('active')
        }
    })
})

Here's what's happening in the above snippet:

This is how Alpine (or Vue/etc. to my knowledge) would work under the hood if you were to render 10k row components that all depended on a single piece of reactive state like: "activeRow".

Now, when a user clicks a row, it becomes "active" and the other rows are "deactivated" automatically.

The problem: Page updates are EXTREMELY slow.

Why? Because every time "activeRow" is changed, ten thousand "watchEffect" callbacks are re-evaluated.

In most apps, declaring a piece of central state, and then depending on it in child components isn't an issue. However, if you're building something with LOTS of components (or "effects"), this is extremely inefficient for the 9,998 components that don't care about the 2 that were affected by the change to "activeRow".

The Solution: A Reactive Switchboard

A "reactive switchboard" is a term I coined just now for this concept. It's very possible that this pattern already has a name, but whatever...

In the current setup, we have a single piece of state and 10k places that depend on it.

What if instead of a single piece of state with 10k different potential values (like we have above), we had 10k different states, each being a boolean that represents a potential value. For example:

// Before
let activeRow = ref(4)

// After
let rowStates = {
    1: ref(false),
    2: ref(false),
    3: ref(false),
    4: ref(true),
    5: ref(false),
    ...
}

Let's tweak the above example to use this pattern:

import { ref, watchEffect } from 'vue'

let rowStates = {}

document.querySelectorAll('tr').forEach((row) => {
    rowStates[row.id] = ref(false)

    row.addEventListener('click', () => {
        rowStates[row.id].value = true
    })

    watchEffect(() => {
        if (rowStates[row.id].value) {
            row.classList.add('active')
        } else {
            row.classList.remove('active')
        }
    })
})

Ok, now as you can see, instead of "activeRow" containing a single row ID, we have "rowStates" which contains 10k items, each key being a row ID, and each value being a reactive boolean determining if it is in an active state or not.

This works like a charm and is wicked fast! Now, because when a single row is clicked, only a single reactive boolean is switched, and only a single effect updates (instead of ten thousand).

There's one problem though. Before, because "activeRow" only ever contained a single value, there was only ever a single row active at a time. The "deactivating" of the previous row was taken care of automatically because each row re-evaluated every time.

In this example, there is no "deactivating" going on. To "deactivate" a row, we would need to find it inside "rowStates" and mark it as "false".

Let's add a bit of code to make that happen:

import { ref, watchEffect } from 'vue'

let rowStates = {}

document.querySelectorAll('tr').forEach((row) => {
    rowStates[row.id] = ref(false)

    row.addEventListener('click', () => {
        // Deactivate the old row...
        for (id in rowStates) {
            if (rowStates[id].value === true) {
                rowStates[id].value = false
                return
            }
        }

        rowStates[row.id].value = true
    })

    watchEffect(() => {
        if (rowStates[row.id].value) {
            row.classList.add('active')
        } else {
            row.classList.remove('active')
        }
    })
})

As you can see we added a bit of code in the click listener to loop through all the rows and deactivate any active ones.

Now that we've added "deactivation", our code isn't AS performant. Every time a row is clicked we are looping through 10k items in the "rowStates" object.

Turns out we can get back to the ideal efficiency we had before by adding a little piece of data to store the currently active row ID. It will serve as a basic memo so we can skip the loop:

import { ref, watchEffect } from 'vue'

let rowStates = {}
let activeRow

document.querySelectorAll('tr').forEach((row) => {
    rowStates[row.id] = ref(false)

    row.addEventListener('click', () => {
        if (activeRow) rowStates[activeRow].value = false

        activeRow = row.id

        rowStates[row.id].value = true
    })

    watchEffect(() => {
        if (rowStates[row.id].value) {
            row.classList.add('active')
        } else {
            row.classList.remove('active')
        }
    })
})

Ok, now that we've added "activeRow", we're back to perfect update performance.

This is good and all, but would feel much better if there we a simple abstraction to take care of all of this legwork for us.

What I ended up with is a little function called "switchboard" that wraps a value and returns a few utility functions for accessing and mutating it.

import { watchEffect } from 'vue'
import { switchboard } from 'reactive-switchboard' // Heads up: this isn't on NPM

let { set: activate, is: isActive } = switchboard(12)

document.querySelectorAll('tr').forEach((row) => {
    row.addEventListener('click', () => {
        activate(row.id)
    })

    watchEffect(() => {
        if (isActive(row.id)) {
            row.classList.add('active')
        } else {
            row.classList.remove('active')
        }
    })
})

Now, with this little switchboard function, we have just as clean-looking code as before, but orders of magnitude more performant.

Here's the full API for switchboard:

import { switchboard } from 'reactive-switchboard' // Heads up: this isn't on NPM

let { get, set, is } = switchboard(12)

// get() returns "12" in this case (non-reactively)
// set(10) sets the internal value to 10
// is(10) runs a reactive comparison to the underlying value

This is extremely useful for things like tracking "active" states because there is only ever a single active value.

I have also found a similar need when tracking "selected" states where there is more than one.

For these cases, I made a new utility called a switchboardSet that has a Set-esque API: (there's probably a much better name for it, but again, whatever...)

import { switchboardSet } from 'reactive-switchboard' // Heads up: this isn't on NPM

let { get, add, remove, has, clear } = switchboardSet([12])

// get() returns [12] (non-reactively)
// add(10) sets the internal array to [12, 10]
// remove(10) sets the array back to [12]
// has(12) returns a reactive boolean
// clear() reactively clears the internal array: []

So there you have it, a problem, solution, and abstraction.

I put the source code for switchboard in a GitHub gist here: https://gist.github.com/calebporzio/3153ddfc31ae5a00463b71b2fc938ca0

Enjoy!


My Newsletter

I send out an email every so often about cool stuff I'm working on or launching. If you dig, go ahead and sign up!