Batched State Mutations
OWL schedules renders asynchronously via microtasks. Multiple synchronous state mutations within the same call stack produce exactly one re-render:
class Dashboard extends Component {
state = useState({ count: 0, label: "", color: "blue" });
updateAll() {
// THREE mutations — ONE re-render (they are batched)
this.state.count++;
this.state.label = `Count is ${this.state.count}`;
this.state.color = this.state.count % 2 === 0 ? "blue" : "red";
// OWL schedules one microtask render after this function returns
}
}
Without batching, each of those three state mutations would trigger a separate render — three DOM updates instead of one, possibly showing intermediate inconsistent states. OWL's microtask scheduler collects all mutations within the same synchronous frame and applies them in one efficient render pass.
What Are Fibers?
A fiber is an internal OWL concept representing one render pass for a component. When a component needs to re-render, OWL creates a fiber for it. If the render requires awaiting async hooks, the fiber is suspended until those promises resolve. This is what allows onWillStart and onWillUpdateProps to pause rendering without blocking the browser event loop.
State mutation detected
│
├─ OWL creates a new fiber for the component
│
├─ Fiber runs onWillPatch (or onWillStart for first mount)
│ → if async: fiber suspends, other work continues, fiber resumes on resolve
│
├─ Fiber runs the template render function (synchronous)
│
├─ Fiber diffs vdom vs previous vdom
│
├─ Fiber patches real DOM
│
└─ Fiber calls onPatched (or onMounted for first mount)
If a new state mutation arrives while fiber is suspended (async):
→ old fiber is CANCELLED, new fiber starts fresh with latest state
If a new render is triggered while an async hook is awaiting, OWL cancels the in-flight fiber. This is correct behavior — it prevents stale renders. But if your async code outside OWL (a setTimeout, a fetch callback) updates state after its fiber was cancelled, the state update will cause a new render. This is safe, but if you are not careful, the "wrong" async result can overwrite the "right" one.
Race Conditions in Async Code
The classic race condition: user triggers two fetches quickly. The first takes longer than the second. The first completes after the second and overwrites the correct result:
// ❌ BROKEN — race condition
class SearchResults extends Component {
state = useState({ results: [], query: "" });
async search(query) {
// User types fast: search("he") then search("hello")
// If search("he") is slower, it will overwrite search("hello") results
const results = await fetchSearch(query);
this.state.results = results; // might be stale!
}
}
// ✅ CORRECT — cancel previous request before starting new one
class SearchResults extends Component {
state = useState({ results: [], isLoading: false, query: "" });
_abortController = null;
async search(query) {
// Cancel the previous in-flight request
this._abortController?.abort();
this._abortController = new AbortController();
this.state.isLoading = true;
try {
const results = await fetchSearch(query, {
signal: this._abortController.signal,
});
this.state.results = results;
this.state.isLoading = false;
} catch (err) {
if (err.name === "AbortError") return; // expected — not an error
this.state.isLoading = false;
}
}
setup() {
onWillUnmount(() => this._abortController?.abort());
}
}
The Request ID Pattern
When AbortController is not available (non-fetch async), use a request ID counter:
class DataPanel extends Component {
state = useState({ data: null, isLoading: false });
_reqId = 0; // monotonically increasing request ID
async loadData(params) {
const myReqId = ++this._reqId; // capture THIS request's ID
this.state.isLoading = true;
const data = await expensiveOperation(params);
// Only update state if this is still the latest request
if (myReqId !== this._reqId) return; // stale — a newer request is pending
this.state.data = data;
this.state.isLoading = false;
}
}
Safe Async State Patterns Summary
| Problem | Solution |
|---|---|
| Stale async result overwrites fresh result | AbortController or request ID pattern |
| State update after component unmounted | _isMounted flag or AbortController aborted on unmount |
| Two fetches running simultaneously | Cancel the previous before starting new (AbortController) |
| Multiple rapid state mutations causing multiple renders | Keep mutations synchronous — OWL batches them automatically |
| UI flickers between states | Set loading state synchronously before await; clear it in finally |
State Mutations Across Awaits
Mutations on opposite sides of an await are NOT batched — each one is in a different microtask:
async loadData() {
this.state.isLoading = true; // Render 1 (microtask queued)
const data = await fetchData(); // ← awaiting suspends this function
// Code below runs in a NEW microtask after await resolves
this.state.data = data; // Render 2 (separate microtask)
this.state.isLoading = false; // Batched with line above → still Render 2
}
// Result: TWO renders:
// 1. isLoading = true → shows spinner
// 2. data = ..., isLoading = false → shows data
// This is usually fine and actually desired:
// the user sees the loading spinner while data is fetched.
The first render (before await) shows the loading state immediately. The second render (after await) shows the data. This is the correct UX pattern — the spinner appears right away, not after the fetch. If you want to avoid any intermediate state, set all state properties in onWillStart — OWL awaits it before the first render, so only one render happens with all data ready.
📋 Summary
- OWL batches synchronous state mutations into a single render via a microtask scheduler.
- Fibers are the internal unit of a render pass. Async hooks (onWillStart, onWillUpdateProps) suspend the fiber until their Promises resolve.
- If new state arrives while a fiber is awaiting, the old fiber is cancelled and a new one starts — this prevents stale renders but means your async callbacks must guard against race conditions.
- Use
AbortControllerto cancel previous fetches when a new request starts. - Use a request ID counter when AbortController is not applicable.
- State mutations across an
awaitcreate two renders — this is intentional (shows loading spinner then data). - Always guard async callbacks that update state against "component unmounted" with an
_isMountedflag or abort signal.
🏋️ Exercise
Build a LiveSearch component that demonstrates safe async concurrency:
- A text input bound to
state.query. On every input event, debounce 300ms then call asearch()method. search()must be race-condition-safe using AbortController. Cancel any in-flight request when a new one starts.- Show a loading indicator while searching. Show "No results" when results are empty. Show the results list when data arrives.
- If the query is cleared (empty string), immediately set results to
[]and cancel any pending request. - In
onWillUnmount, abort any pending request. - Test by typing quickly — only the final result should appear, never an earlier stale result.
FAQ
In most cases you should not need to. OWL renders within the same microtask queue as Promise resolutions, so by the time your code continues after an await, OWL has already rendered. If you truly need a synchronous paint (e.g., for layout measurements immediately after state change), the browser provides document.body.getBoundingClientRect() which forces a synchronous layout. But manipulate the DOM directly only as a last resort — prefer reading measurements in onPatched.
Queuing would produce renders with stale intermediate state — the user might see data from a previous query flash before the new data appears. Cancellation ensures that only the most current state ever makes it to the DOM. It mirrors how browsers handle rapid user input: only the last action produces a visible result. The trade-off is that your async code must be designed to handle cancellation gracefully (via abort signals or request IDs).