Glitch free 1,000,000 record data processing in TypeScript with js-coroutines

Mike Talbot ⭐ - Jul 10 '20 - - Dev Community

Sometimes we need to process data on the front end, perhaps we are using an offline system or accessing local data. When that data gets large it can easily cause the UI to glitch. A few days ago I wrote an article demonstrating how search could be made to run at the same time as UI updates using js-coroutines. I thought I'd dive into a more powerful version in TypeScript that does more than search; it also renders the records as it goes and has a variety of progress indicators. Once done it performs a bunch of tabulations to update some charts.

Notice how you can keep typing and even start browsing the records as the searches continue. This is done using collaborative multitasking on the main thread.


Please note the tooltip supplied by Recharts doesn't work properly when this window is zoomed. See full screen version

This demo uses a new feature of js-coroutines that allows you to define a "singleton" function. Singleton functions automatically cancel the previous run if it is still underway and start again. That's exactly what you need for a search like this.

const process = singleton(function*(resolve: Function, search: string, sortColumn: string) {
    let yieldCounter = 0

    if (!search.trim() && !sortColumn?.trim()) {
        resolve({ data, searching: false })
        addCharts(data)
        return
    }

    resolve({ searching: true, data: [] })
    let parts = search.toLowerCase().split(" ")
    let i = 0
    let progress = 0

    let output : Data[] = []
    for (let record of data) {
        if (
            parts.every(p =>
                record.description
                    .split(" ")
                    .some(v => v.toLowerCase().startsWith(p))
            )
        ) {
            output.push(record)
            if (output.length === 250) {
                resolve({data: output})
                yield sortAsync(output, (v : Data)=>v[sortColumn])
            }
        }
        let nextProgress = ((i++ / data.length) * 100) | 0
        if (nextProgress !== progress) resolve({ progress: nextProgress })
        progress = nextProgress
        yield* check()
    }
    resolve({sorting: true})
    yield sortAsync(output, (v : Data)=>v[sortColumn])
    resolve({sorting: false})
    resolve({ searching: false, data: output })
    addCharts(output)

    function* check(fn?: Function) {
        yieldCounter++
        if ((yieldCounter & 127) === 0) {
            if (fn) fn()
            yield
        }
    }
}, {})
Enter fullscreen mode Exit fullscreen mode

This routine starts off by checking if we are searching for something and takes a quicker path if we aren't.

Presuming it is searching it uses a neat trick of resolving values many times to update the progress. This allows it to reveal results as soon as it has 250 records, update progress every 1% and then switch on and off searching and sorting indicators.

Calling resolve just merges some data into a standard React.useState() which redraws the UI to keep everything smoothly updating while the search progresses.

interface Components {
    data?: Array<Data>
    searching?: boolean
    progress?: number,
    sorting?: boolean,
    charts?: []
}

function UI(): JSX.Element {
    const [search, setSearch] = React.useState("")
    const [sortColumn, setSortColumn] = React.useState('')
    const [components, setComponents] = React.useState<Components>({})
    React.useEffect(() => {
        setComponents({ searching: true })
        // Call the singleton to process
        process(merge, search, sortColumn)
    }, [search, sortColumn])
    return (
        <Grid container spacing={2}>
            <Grid item xs={12}>
                <TextField
                    fullWidth
                    helperText="Search for names, colors, animals or countries.  Separate words with spaces."
                    InputProps={{
                        endAdornment: components.searching ? (
                            <CircularProgress color="primary" size={"1em"} />
                        ) : null
                    }}
                    variant="outlined"
                    value={search}
                    onChange={handleSetSearch}
                    label="Search"
                />
            </Grid>

                <Grid item xs={12} style={{visibility: components.searching ? 'visible' : 'hidden'}}>
                    <LinearProgress
                        variant={components.sorting ? "indeterminate": "determinate"}
                        value={components.progress || 0}
                        color="secondary"
                    />
                </Grid>

            <Grid item xs={12}>
                <RecordView sortColumn={sortColumn} onSetSortColumn={setSortColumn} records={components.data} />
            </Grid>
            {components.charts}
        </Grid>
    )
    function merge(update: Components): void {
        setComponents((prev: Components) => ({ ...prev, ...update }))
    }
    function handleSetSearch(event: React.ChangeEvent<HTMLInputElement>) {
        setSearch(event.currentTarget.value)
    }
}
Enter fullscreen mode Exit fullscreen mode

The merge function does the work of updating things as the routine progresses, and as we've defined a "singleton" function, it is automatically stopped and restarted whenever the search or sort properties change.

The charts each individually start a calculation, and we "join" their execution to the main process so that restarting the main process will also restart the chart.

function Chart({data, column, children, cols} : {cols?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12, data: Array<Data>, column: (row: any)=>string, children?: any}) {
    const [chartData, setData] = React.useState()
    React.useEffect(()=>{
        const promise = run(count(data, column))

        // Link the lifetime of the count function to the
        // main process singleton
        process.join(promise).then((result: any)=>setData(result))

    }, [data, column])
    return <Grid item xs={cols || 6}>
        {!chartData ? <CircularProgress/> : <ResponsiveContainer width='100%' height={200}>
            <BarChart data={chartData}>
                <CartesianGrid strokeDasharray="3 3" />
                <XAxis dataKey="name" />
                <YAxis />
                <Tooltip />
                <Bar dataKey="value" fill="#8884d8">
                    {children ? children(chartData) : null}
                </Bar>
            </BarChart>
            </ResponsiveContainer>}
        </Grid>
}
Enter fullscreen mode Exit fullscreen mode

Here we've use a mix of helper Async functions and generators so we have maximum control. Our final remaining generator of interest is the one that calculates the chart results:

function * count(data: Data[], column: (row: Data)=>string, forceLabelSort?: boolean) : Generator<any, Array<ChartData>, any> {
    const results = yield reduceAsync(data, (accumulator: any, d: Data)=>{
        const value = column(d)
        accumulator[value] = (accumulator[value] || 0) + 1
        return accumulator
    }, {})
    let output : Array<ChartData> = []
    yield forEachAsync(results, (value: number, key: string)=>{
        key && output.push({name: key, value})
    })
    if(output.length > 20 && !forceLabelSort) {
        yield sortAsync(output, (v:ChartData)=>-v.value)
    } else {
        yield sortAsync(output, (v:ChartData)=>v.name)
    }
    return output
}
Enter fullscreen mode Exit fullscreen mode

This one simply counts the labels extracted by a function and then sorts the results appropriately.

Alt Text

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player