React is a framework

Recently I was debugging a piece of rather legacy React code and I’ve realized that some of the lazy components weren’t unmounting properly for some reason, causing a cascade of additional problems. The page was using react-loosely-lazy to manage lazy loading of components and the bug was only happening when the page was being server side rendered plus some other combination of things that was very difficult to isolate. The library, being implemented way before React supported Suspense properly on the server, was inserting mysterious input elements when the error reproduces. Apparently, those are there for hydrating things properly while rendering the actual component on SSR via some additional babel plugin trickery.

After getting hold of an environment where the bug was consistently reproducing, I started to individually supress usages of the many lazy components. Unless I leave a single component on the page, it was very difficult to keep track of stuff because the only way to debug the issue was via console.logs due to the racy nature of the reproduction. Still being a mystery to me, only some of the lazy components triggered the bug when things got intertwined with what was happening on the page logic. Nevertheless, I’ve isolated a single component and debug traced the whole lifecycle. It boiled down to a wrapper component being rendered a second time after the internal React lazy component has unsuspended.

Even though the exact working mechanism/reasoning is still somewhat shady to me, the library renders the fallback component directly for the initial render and depends on a native Suspense fallback to cleanup this state and go back to rendering the original children. In my debugging session, this cleanup wasn’t happening when the component rendered a second time. This meant that the fallback is never triggered and presumably the actual children is immediately rendered instead. Not knowing the exact workings of React Suspense, I rolled up a simple test to verify the inner workings.

import React, { lazy, version } from 'react';
import { render, screen, waitFor } from 'react-testing-library';

it('should not show fallback for an already resolved lazy component for legacyRoot: %s',
    async (legacyRoot) => {
        const promise = await Promise.resolve({ default: () => null });
        const LazyComponent = lazy(() => promise);

        render(
            <React.Suspense fallback={<div>loading...</div>}>
                <LazyComponent />
            </React.Suspense>
        );

        expect(screen.getByText('loading...')).toBeInTheDocument();

        await waitFor(() => expect(screen.queryByText('loading...')).not.toBeInTheDocument());

        render(
            <React.Suspense fallback={<div>loading2...</div>}>
                <LazyComponent />
            </React.Suspense>
        );

        expect(screen.queryByText('loading2...')).not.toBeInTheDocument();
    },
);

At first, I thought it was caused by an already resolved promise and based my tests around that but those properly rendered the fallback (It is clear to me now that there is no way for React to discriminate an already resolved promise in hindsight). What happens is, if a lazy component resolves once, React keeps some piece of information attached to that component and rendering it in any React tree from now on will not suspend anymore. Even though this makes perfect sense in terms of performance & UX, I always think of React keeping track of state on the component tree (figuratively). This behaviour of having some state on a component reference felt very counterintuitive to me. I even had to test this exact behaviour to convince myself.

In the end I’ve found a solution to the problem by cleaning up the state if the subtree didn’t suspend but this also made me realize React is bringing a lot of (in my view unnecessary) complexity to the table especially with the latest addition of concurrent features. It used to be much simpler. You’d have some components that followed one directional data flow and it almost felt like a library b/c you felt in full control. With the addition of hooks (even though I also prefer them over class components), it started to “leak” its internals. It introduced “rules” (that also got an upgrade later) that you must follow.

While I understand how purity & rules enable the latest optimizations with concurrent features, this latest finding reminded me that React is not a library but indeed a framework because it is clearly “calling” our components on its own terms and creating more situations to shoot yourself in the foot at the same time. Server components is an extension to the same model, but don’t get me started on that. It was supposed to simplify how we develop things but unfortunately it does it in a way that makes things more complicated these days.

Reach me out for any comments or questions on Twitter.

© Ali Naci Erdem 2025