1. Where this came from

A while back a friend mentioned microtasks in JavaScript. I hadn't heard of them at the time, so I went to the MDN docs[1] to find out. I'll be honest, even after a few reads I couldn't quite make heads or tails of it. What I needed was a concrete implementation I could hold in my hands.

So I borrowed a shape I already knew - useState[2] in React - and decided to try rebuilding the fundamentals of it using microtasks to understand where microtasks fit, how they work, and where they bite. It was also just a bit of fun - a little challenge.

After some trial and error I ended up with the code below. It's small, but it gets the mechanics across, and it got them across to me, which is all I was after in the end.

2. The code

tsmicrotaskUseState.ts
type MicrotaskUseStateResult<T> = [() => T, (newValue: T) => void];

const microtaskUseState = <T,>(initialValue: T): MicrotaskUseStateResult<T> => {
  let internalValue = initialValue;
  let microtaskValue = initialValue;
  let microtaskUpdated = false;

  const setter = (newValue: T) => {
    microtaskValue = newValue;

    if (!microtaskUpdated) {
      microtaskUpdated = true;

      queueMicrotask(() => {
        internalValue = microtaskValue;
        microtaskUpdated = false;
      });
    }
  };

  return [() => internalValue, setter];
};

const [count, setCount] = microtaskUseState(0);

setCount(1);
setCount(2);

console.log(count()); // before the tick, returns 0 - not 2

setTimeout(() => console.log(count()), 0); // after the tick, returns 2 - the final staged value

Two things need to be kept in mind:

  • The read you do straight after a set (microtasks drain at the end of the current task, before the next macrotask runs).
  • The assumption that microtasks will drain before your next observation.

3. Breaking it down

ts
type MicrotaskUseStateResult<T> = [() => T, (newValue: T) => void];

First, the return type - a getter and a setter. The getter is the first real deviation from React. I kept it as it is here for simplicity in the typing, but an explicit get accessor is another option. One way or another, you don't hold the value, you ask for it. That matters, because the whole point is that the value you ask for might not be the one you just set. And you might not expect that at first.

(A small thing - <T,> rather than <T> is just JSX-friendly syntax. Nothing deeper than that.)

ts
const microtaskUseState = <T,>(initialValue: T): MicrotaskUseStateResult<T> => {
  let internalValue = initialValue;
  let microtaskValue = initialValue;
  let microtaskUpdated = false;

Three variables. The value you're allowed to read, the value waiting to be committed, and a flag. The middle one is the important one, and it lives outside the microtask's callback on purpose.

ts
  const setter = (newValue: T) => {
    microtaskValue = newValue;

    if (!microtaskUpdated) {
      microtaskUpdated = true;

      queueMicrotask(() => {
        internalValue = microtaskValue;
        microtaskUpdated = false;
      });
    }
  };

  return [() => internalValue, setter];
};

The setter writes to the staging variable immediately, and that's what makes batching work. If the assignment happened inside the microtask instead, every call except the first would be lost - you'd always commit the first value you set. This one caught me out while writing it.

Then the flag. Only the first call in a tick queues a commit, and the rest overwrite the staging value and leave the queue alone. That's why two writes in one tick collapse to the last one.

The flag could be avoided with a more reliable comparison between the old and new value. It's here for simplicity.

When the microtask runs, it copies staged into committed and clears the flag.

ts
const [count, setCount] = microtaskUseState(0);

setCount(1);
setCount(2);

console.log(count()); // before the tick, returns 0 - not 2

Then you use it the way you'd expect - set a value, set another, read it back.

And this is another part that caught me out. The read gives you the initial value, not either of the ones you just set. Nothing is committed yet. The microtask hasn't run. It's the same surprise as calling setState three times in one function and reading the value straight after.

ts
setTimeout(() => console.log(count()), 0); // after the tick, returns 2 - the final staged value

The timeout is only an observation window. Microtasks drain before macrotasks, so a zero-delay timeout is the cheapest way to look at the value after the commit.

4. The demo

One button. It starts at 0. Press it and watch the readout against the log - the value holds while two writes are staged, then lands on the second.

committed0

    5. What it costs

    Microtasks can enqueue each other. A microtask that queues another will have that one run before the task queue gets a look in, and if you do it recursively you can starve everything else. Nothing renders, nothing responds, and the page looks hung while the queue is still busy doing nothing useful.

    Ordering is the other one. Any promise or await in the neighbourhood interleaves with your commit, so where your value lands isn't always where you assumed. That's fine while you're the only thing scheduling. It stops being fine the moment something else is.

    The docs take a position on this themselves:

    As such, you should typically use microtasks only when there's no other solution, or when creating frameworks or libraries that need to use microtasks in order to create the functionality they're implementing.

    - MDN, Using microtasks in JavaScript with queueMicrotask()

    I'd go further: the odds of shooting yourself in the foot here are better than the odds of a tangible benefit. And I say that as someone who still barely understands the implications.

    There's probably something in it for form validation. For day to day, though, you're probably better off with something higher level and familiar.

    6. The conclusion

    I wasn't trying to reverse engineer React at the start, and as far as I know it doesn't store hook state in microtasks anyway, only involves them in scheduling. Building something small and watching it misbehave along the way did more than reading about it. I understand microtasks a lot better now, which is to say still very little.