loading...
Cover image for Search as you type at 60fps with js-coroutines

Search as you type at 60fps with js-coroutines

miketalbot profile image Mike Talbot ・2 min read

It's nice to be able to make user interfaces that require the least number of clicks for the user to achieve their goal. For instance, we might want to search a list as we type. The challenge is though, as the list gets bigger there's a chance that the whole user experience will degrade as our JavaScript hogs the main thread stopping animations and making the whole experience glitchy.

This article will show how we can quickly modify a standard search function to use js-coroutines and keep the fully responsive experience with very little extra effort.

Let's say we have a list of 1,000,000 items and we have a text box, as the user types, we'd like to return the first 50 entries that have words that match the words they've typed (in any order).

For this example, we'll use "unique-names-generator" to create a list of nonsense to search on! Entries will look a little like this:

Aaren the accused lime flyingfish from Botswana
Adriana the swift beige cuckoo from Botswana

Our search function is pretty simple:

function find(value) {
    if (!value || !value.trim()) return []
    value = value.trim().toLowerCase()
    const parts = value.split(" ")
    return lookup
        .filter(v =>
            parts.every(p =>
                v.split(" ").some(v => v.toLowerCase().startsWith(p))
            )
        )
        .slice(0, 50)
}

But with 1,000,000 entries the experience is pretty woeful. Try searching the in the screen below for my favourite dish: 'owl rare', and watch the animated progress circle glitch...

This experience is atrocious and we'd have to either remove the functionality or find a much better way of searching.

js-coroutines to the rescue!

With js-coroutines we can just import the filterAsync method and re-write our "find" to be asynchronous:

let running = null
async function find(value, cb) {
    if (running) running.terminate()
    if (!value || !value.trim()) {
        cb([])
        return
    }
    value = value.trim().toLowerCase()
    let parts = value.split(" ")
    let result = await (running = filterAsync(
        lookup,

        v =>
            parts.every(p =>
                v.split(" ").some(v => v.toLowerCase().startsWith(p))
            )
    ))
    if (result) {
        cb(result.slice(0, 50))
    }
}

Here you can see we terminate any currently running search when the value changes, and we've just added a callback, made the function async and that's about it.

The results are much better:

Alt Text

Posted on by:

Discussion

markdown guide
 

This is really awesome mike. great work.

btw can we also show a spinner or loading text while the calculations are happening in the background because UX wise i didn't feel right because as a user i did not get any feedback after typing on the input box

 

Yes for sure, I've added it to the demo. Basically you just need to set and remove the searching element - you could even have "real" progress I guess. I changed the app to look like this:

export default function App() {
    const [value, setValue] = React.useState("")
    const [list, setList] = React.useState([])
    const [searching, setSearching] = React.useState(false)
    React.useEffect(() => {
        setSearching(true)
        find(value, results => {
            setList(results)
            setSearching(false)
        })
    }, [value])
    return (
        <div className="App">
            <h1>
                <a href="http://js-coroutines.com">js-coroutines</a> and
                1,000,000 entries <CircularProgress color="secondary" />
            </h1>
            <div
                style={{ display: "flex", alignItems: "center", width: "100%" }}
            >
                <div style={{ flexGrow: 1 }} />
                <input
                    style={{ marginRight: 8 }}
                    value={value}
                    placeholder="type a search"
                    onChange={({ target: { value } }) => setValue(value)}
                />
                <div style={{ opacity: searching ? 1 : 0 }}>
                    <CircularProgress size="1.2em" color="primary" />
                    <em
                        style={{
                            marginLeft: 8,
                            fontSize: "80%",
                            color: "#ccc"
                        }}
                    >
                        Searching...
                    </em>
                </div>
                <div style={{ flexGrow: 1 }} />
            </div>
            {!!list.length && <h3>Recommendations</h3>}
            <ul>
                {list.map((item, index) => {
                    return <li key={index}>{item}</li>
                })}
            </ul>
        </div>
    )
}
 

This definitely has my attention.

 

How come there isn't more focus on using another thread via web workers in JS?

I've come across from using other languages where kicking off jobs in another thread is pretty simple and so I don't understand why it seems to be so niche in the web world. Is it just about browser compatibility?

Super article though, I'll give this a go later

 

Hey, totally use another thread when it makes sense. I do. However, moving stuff around in JS is difficult due to the sandboxing. So for instance in my code I do nearly all my processing on a worker thread that can get the data from an IndexedDb database - however, there's a lot of cases where you aren't in your "core" code and then this stuff helps a lot to avoid a glitch.