Konrad Höffner
Konrad Höffner

Reputation: 12207

How to give instant user feedback on slow checkbox listeners in JavaScript?

I have a checkbox with a listener attached that executes synchronous code that takes several seconds to complete. The checkbox only shows its updated state (checked or unchecked) after the operation is complete. This confuses users, who think the click didn't register and then click twice, which triggers the operation an additional time.

How can I get the user to see the change in checked state immediately?

const checkbox = document.getElementById("checkbox");
const label = document.getElementById("label");
checkbox.addEventListener("change",()=>
{
 for(let i=0;i<2_000_000_000;i++) {const j=i;} // long running synchronous operation
 label.innerText+=" clicked";
});
<input type="checkbox" id="checkbox">
<label for="checkbox" id="label">Click me</span>

Tested on Firefox Developer Edition 82.0b5.

Upvotes: 0

Views: 125

Answers (1)

T.J. Crowder
T.J. Crowder

Reputation: 1074465

Your best option is to have the slow synchronous code run on a separate thread via web workers or similar, so it doesn't freeze the UI of the page. (You might make the checkbox disabled while the work is ongoing.) That would also let you implement the progress bar that brk suggested, by having the web worker post updated back to the main thread.

Second best if you can't move the work off the main thread would be to delay the start of it briefly so the browser has time to draw the updated checkbox by doing setTimeout(() => { /* ... */ }, 50); (a value of 50 seems to work well cross-browser, whereas 0 works on Chrome but not reliably on Firefox) or something slightly more sophisticated (below).

const checkbox = document.getElementById("checkbox");
const label = document.getElementById("label");
checkbox.addEventListener("change", () => {
    label.innerText += " clicked";
    checkbox.disabled = true;
    setTimeout(() => {
        for (let i = 0; i < 2_000_000_000; i++) {
            const j=i;
        }
        checkbox.disabled = false;
        console.log("Done");
    }, 50);
});
<input type="checkbox" id="checkbox">
<label for="checkbox" id="label">Click me</span>

You might also break the work up into chunks, so that you're only freezing the UI for briefer periods of time. That would also let you update a progress bar.


A slightly more sophisticated version of the the setTimeout above can be made using requestAnimationFrame.

const checkbox = document.getElementById("checkbox");
const label = document.getElementById("label");
checkbox.addEventListener("change", () => {
    label.innerText += " clicked";
    checkbox.disabled = true;
    afterRedraw(() => {
        for (let i = 0; i < 2_000_000_000; i++) {
            const j=i;
        }
        checkbox.disabled = false;
        console.log("Done");
    });
});
function afterRedraw(callback) {
    requestAnimationFrame(() => {
        setTimeout(callback, 0);
    });
}
<input type="checkbox" id="checkbox">
<label for="checkbox" id="label">Click me</span>


FWIW, here's a bare-bones example of doing this (but with a result we can send back) using a worker (plunker):

HTML:

<input type="checkbox" id="checkbox">
<label for="checkbox" id="label">Click me</span>
<script src="script.js"></script>

script.js:

const checkbox = document.getElementById("checkbox");
const label = document.getElementById("label");
const worker = new Worker("./worker.js");
checkbox.addEventListener("change", () => {
    label.innerText += " clicked";
    checkbox.disabled = true;
    // Tell the worker to do the work
    worker.postMessage({
        command: "sum",
        size: 2000000000
    })
});

// Handle responses from worker
worker.addEventListener("message", evt => {
    const { command, sum } = evt.data;
    if (command === "sum") {
        checkbox.disabled = false;
        console.log(`Done, sum = ${sum}`);
    }
});

worker.js:

this.addEventListener("message", evt => {
    const { command } = evt.data;
    if (command === "sum") {
        console.log("Summing");
        let sum = 0;
        for (let i = 0; i < 2_000_000_000; ++i) {
            sum += i;
        }
        console.log("Done");
        this.postMessage({
            command: "sum",
            sum
        });
    }
});

Here's an example with a more generalized infrastructure for sending tasks to the worker (same HTML; plunker):

script.js:

const checkbox = document.getElementById("checkbox");
const label = document.getElementById("label");

checkbox.addEventListener("change", () => {
    label.innerText += " clicked";
    checkbox.disabled = true;
    startWorkerTask({
        command: "sum",
        size: 2000000000
    })
    .then(sum => {
        console.log(`Done, sum = ${sum}`);
    })
    .catch(error => {
        console.error(`Error running task: ${error.message}`);
    })
    .finally(() => {
        checkbox.disabled = false;
    });
});

// Reusable worker infrastructure
const worker = new Worker("./worker.js");
let nextMessageId = 1;
const pendingTasks = new Map();

// Handle responses from worker
worker.addEventListener("message", evt => {
    const { messageId, success, result, error } = evt.data;
    const task = pendingTasks.get(messageId);
    if (task) {
        if (success) {
            task.resolve(result);
        } else {
            task.reject(error);
        }
        pendingTasks.delete(messageId);
    }
});

function startWorkerTask(data) {
    return new Promise((resolve, reject) => {
        const messageId = nextMessageId++;
        pendingTasks.set(messageId, {resolve, reject});
        worker.postMessage({
            messageId,
            ...data
        });
    });
}

worker.js:

const commands = {
    sum(command, data) {
        return new Promise(resolve => {
            console.log("Summing");
            const { size = 2_000 } = data;
            let sum = 0;
            for (let i = 0; i < size; ++i) {
                sum += i;
            }
            console.log("Done");
            resolve(sum);
        });
    }
};
function unknownCommand(command) {
    return Promise.reject(new Error(`Unknown command "${command}"`));
}
self.addEventListener("message", ({data}) => {
    const { command, messageId } = data;
    const action = commands[command] || unknownCommand;
    action(command, data)
    .then(
        result => {
            self.postMessage({
                messageId,
                success: true,
                result
            });
        },
        error => {
            self.postMessage({
                messageId,
                success: false,
                error
            });
        }
    );
});

Upvotes: 1

Related Questions