
The other day while implementing toast messages using toaster from chakraui, I got the following error:
useInertiaToast.ts:11 flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering. Consider moving this call to a scheduler task or micro task.
Here is the code that I was implementing
import { useEffect } from "react";
import { usePage } from "@inertiajs/react";
import InertiaSharedProps from "@/types/inertia";
import { toaster } from "@/components/ui/toaster";
const useInertiaToasts = () => {
const { flash } = usePage<InertiaSharedProps>().props;
useEffect(() => {
if (flash.alert) {
toaster.error({ title: flash.alert });
}
if (flash.notice) {
toaster.success({ title: flash.notice });
}
}, [flash.alert, flash.notice]);
};
export default useInertiaToasts;
It is clearly stated in the error message that we can move this call onto a scheduler task or micro task. This exposed a part of the web that I knew about but didn't have much depth into. So I decided to dig in and understand the fundamentals. Before explaining what this error is and why this happens, I think we can start with React's render cycle.
React's rendering cycle involves three main phases: trigger, render, and commit.
Trigger is something that causes React to queue a render or re-render. Initially, React renders using the createRoot.render method. After the initial render, React will re-render based on certain triggers, such as a set function.
Render phase calculates what React should render in the current iteration. It will calculate the difference between the new render and the previous one . Then it will figure out what needs to change, but it won't do anything with that information until the next step. Also, it's important to remember that rendering in React, in its most basic form, is just calling your components. Components are just JavaScript functions that return JSX.
Commit phase is where react updates the DOM nodes to reflect the changes. It doesn't replace the whole DOM, but instead only the nodes that changed between renders.
Painting - This is the browser's job, not React's. Its when the browser visually paints the DOM changes onto the screen
One important detail is that effects run after the commit phase.
flushSync allows you to immediately update the DOM by forcing React to run the render and commit phases synchronously, rather than scheduling them.
This breaks useful React features such as state update batching as shown below.
setValue(value1);
setValue(value2);
setValue(value3);
// React will batch these and only updates once! (efficient).
// React will now go through the Render -> Commit -> Paint -> Effects cycle.
// DOM gets updated at the painting phase.
flushSync(() => setValue(value1));
// DOM is updated right NOW!
The toast from ChakraUI internally uses flushSync. And because I called flushSync from inside a useEffect, it conflicted with React's current rendering cycle.
A mental model of why this is problematic
// React's internal flow simplified:
function performUpdate() {
renderComponent(); // 1
commitToDOM(); // 2
runEffects(); // 3 <- Your useEffect runs here.
// If you call flushSync here, you're calling
// performUpdate() again while inside it!
cleanupComplete(); // 4
}
queueMicroTask is a javascript API. Let's have a quick refresher on how javascript event loop works
JS event loop consists of mainly the call stack, microtask queue and macrotask queue. Here is a mental model of how this works in action
┌─────────────────────────────────────────┐
Call Stack (Sync Code)
- Function calls execute here
- React's update cycle runs here
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
Microtask Queue
- Promise.then()
- queueMicrotask()
- MutationObserver
- Process.nextTick (Node.js)
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
Macrotask Queue
- setTimeout/setInterval
- setImmediate (Node.js)
- I/O operations
- UI rendering
└─────────────────────────────────────────┘
↓
┌─────────────────────────────────────────┐
Browser Paint/Render
└─────────────────────────────────────────┘
As you can see above, React's render cycle runs on the call stack. The task we schedule with queueMicrotask waits in the microtask queue. So when we wrap flushSync in queueMicrotask, it's scheduled to only run after React's current update is finished. This avoids the conflict and safely triggers the next update for the toast.