React I Love You, But You're Bringing Me Down

François Zaninotto
François ZaninottoSeptember 20, 2022
#react#popular

Dear React.js,

We've been together for almost 10 years. We've come a long way. But things are getting out of hand. We need to talk.

It's embarrassing, I know. Nobody wants to have that conversation. So instead, I'll say it in songs.

You Were The One

I'm not a first-time JS lover. I've had long adventures with jQuery, Backbone.js, and Angular.js before you. I knew what I could expect from a relationship with a JavaScript framework: Better UIs, more productivity, and a smoother developer experience - but also the frustrations of having to constantly change the way I think about my code to match the framework's mindset.

And when I met you, I was just out of a long relationship with Angular.js. I was exhausted by watch and digest, not to mention scope. I was looking for something that didn't make me feel miserable.

It was love at first sight. Your one-way data binding was so refreshing compared to all I knew at the time. An entire category of problems I had with data synchronization and performance simply didn't exist with you. You were pure JavaScript, not a poor imitation expressed as a string in an HTML element. You had this thing, the "declarative component", which was so beautiful that everyone kept looking at you.

React meme

You were not the easy type. I had to work on my coding habits to get along with you, but boy was it worth it! At first, I was so happy with you that I kept telling everyone about you.

Heroes Of New Forms

Things started getting weird when I asked you to handle forms. Forms and input are hard to cope with in vanilla JS. With React, they're even harder.

First, developers have to choose between controlled and uncontrolled inputs. Both have drawbacks and bugs in corner cases. But why do I have to make a choice in the first place? Two form strategies are one too many.

The "recommended" way, controlled components, is super verbose. This is the code I need for an addition form:

import React, { useState } from 'react';

export default () => {
    const [a, setA] = useState(1);
    const [b, setB] = useState(2);

    function handleChangeA(event) {
        setA(+event.target.value);
    }

    function handleChangeB(event) {
        setB(+event.target.value);
    }

    return (
        <div>
            <input type="number" value={a} onChange={handleChangeA} />
            <input type="number" value={b} onChange={handleChangeB} />

            <p>
                {a} + {b} = {a + b}
            </p>
        </div>
    );
};

And if there were only two ways, I'd be happy. But because of the huge amount of code required to build a real-life form with default values, validation, dependent inputs, and error messages, I have to use a third-party form framework. And they all fall short one way or another.

  • Redux-form looked like a natural choice when we used Redux, but then his lead developer abandoned it to build
  • React-final-form, which is full of unfixed bugs, and the lead developer left again. So I looked at
  • Formik, popular but heavyweight, slow for large forms, and limited in terms of features. So we decided to use
  • React-hook-form, which is fast, but has hidden bugs and has documentation structured like a maze.

After years of building forms with React, I still struggle to make robust user experiences with legible code. When I look at how Svelte deals with forms, I can't help but feel I'm tied with the wrong abstraction. Look at this addition form:

<script>
    let a = 1;
    let b = 2;
</script>

<input type="number" bind:value={a}>
<input type="number" bind:value={b}>

<p>{a} + {b} = {a + b}</p>

You're Too Context Sensitive

Shortly after we first met, you introduced me to your puppy Redux. You couldn't go anywhere without it. I didn't mind at first, because it was cute. But then I realized that the world was spinning around it. Also, it made things harder when building a framework - other developers couldn't easily tweak an app with existing reducers.

But you noticed it, too, and you decided to get rid of Redux in favor of your own useContext. Except that useContext lacks a crucial feature of Redux: the ability to react to changes in parts of the context. These two are NOT equivalent in terms of performance:

// Redux
const name = useSelector(state => state.user.name);
// React context
const { name } = useContext(UserContext);

Because in the first example, the component will only rerender if the user name changes. In the second example, the component will rerender when any part of the user changes. This matters a lot, to the point that we have to split contexts to avoid unnecessary rerenders.

// this is crazy but we can't do otherwise
export const CoreAdminContext = props => {
    const {
        authProvider,
        basename,
        dataProvider,
        i18nProvider,
        store,
        children,
        history,
        queryClient,
    } = props;

    return (
        <AuthContext.Provider value={authProvider}>
            <DataProviderContext.Provider value={dataProvider}>
                <StoreContextProvider value={store}>
                    <QueryClientProvider client={queryClient}>
                        <AdminRouter history={history} basename={basename}>
                            <I18nContextProvider value={i18nProvider}>
                                <NotificationContextProvider>
                                    <ResourceDefinitionContextProvider>
                                        {children}
                                    </ResourceDefinitionContextProvider>
                                </NotificationContextProvider>
                            </I18nContextProvider>
                        </AdminRouter>
                    </QueryClientProvider>
                </StoreContextProvider>
            </DataProviderContext.Provider>
        </AuthContext.Provider>
    );
};

this is crazy

Most of the time, when I have a performance problem with you, it's because of a large context, and I have no choice but to split it.

I don't want to use useMemo or useCallback. Useless rerenders is your problem, not mine. But you force me to do it. Look at how I'm supposed to build a simple form input to make it reasonably fast:

// from https://react-hook-form.com/advanced-usage/#FormProviderPerformance
const NestedInput = memo(
    ({ register, formState: { isDirty } }) => (
        <div>
            <input {...register('test')} />
            {isDirty && <p>This field is dirty</p>}
        </div>
    ),
    (prevProps, nextProps) =>
        prevProps.formState.isDirty === nextProps.formState.isDirty,
);

export const NestedInputContainer = ({ children }) => {
    const methods = useFormContext();

    return <NestedInput {...methods} />;
};

It's been 10 years, and you still have that flaw. How hard is it to offer a useContextSelector?

You're aware of this, of course. But you're looking elsewhere, even though it's probably your most important performance bottleneck.

I Want None Of This

You've explained to me that I shouldn't access DOM nodes directly, for my own good. I never thought that the DOM was dirty, but as it disturbed you, I stopped doing it. Now I use refs, as you asked me to.

But this ref stuff spreads like a virus. Most of the time, when a component uses a ref, it passes it to a child component. If that second component is a React component, it must forward the ref to another component, and so on, until one component in the tree finally renders an HTML element. So the codebase ends up forwarding refs everywhere, reducing the legibility in the process.

Forwarding refs could be as simple as this:

const MyComponent = props => <div ref={props.ref}>Hello, {props.name}!</div>;

But no, that would be too easy. Instead, you've invented this react.forwardRef abomination:

const MyComponent = React.forwardRef((props, ref) => (
    <div ref={ref}>Hello, {props.name}!</div>
));

Why is it so hard, you may ask? Because you simply can't make a generic component (in the sense of Typescript) with forwardRef.

// how am I supposed to forwardRef to this?
const MyComponent = <T>(props: <ComponentProps<T>) => (
    <div ref={/* pass ref here */}>Hello, {props.name}!</div>
);

Besides, you've decided that refs are not only DOM nodes, they're the equivalent of this for function components. Or, to put it otherwise, "state that doesn't trigger a rerender". In my experience, each time I have to use such a ref, it's because of you - because your useEffect API is too weird. In other terms, refs are a solution to a problem you created.

The Butterfly (use) Effect

Speaking of useEffect, I have a personal problem with it. I recognize that it's an elegant innovation that covers mount, unmount and update events in one unified API. But how is this supposed to count as progress?

// with lifecycle callbacks
class MyComponent {
    componentWillUnmount: () => {
        // do something
    };
}

// with useEffect
const MyComponent = () => {
    useEffect(() => {
        return () => {
            // do something
        };
    }, []);
};

You see, this line alone represents the griefs I have with your useEffect:

    }, []);

I see such cryptic suites of cabalistic signs all over my code, and they're all because of useEffect. Plus, you force me to keep track of dependencies, like in this code:

// change page if there is no data
useEffect(() => {
    if (
        query.page <= 0 ||
        (!isFetching && query.page > 1 && data?.length === 0)
    ) {
        // Query for a page that doesn't exist, set page to 1
        queryModifiers.setPage(1);
        return;
    }
    if (total == null) {
        return;
    }
    const totalPages = Math.ceil(total / query.perPage) || 1;
    if (!isFetching && query.page > totalPages) {
        // Query for a page out of bounds, set page to the last existing page
        // It occurs when deleting the last element of the last page
        queryModifiers.setPage(totalPages);
    }
}, [isFetching, query.page, query.perPage, data, queryModifiers, total]);

See this last line? I have to make sure I include all reactive variables in the dependency array. And I thought that reference counting was a native feature of all languages with a garbage collector. But no, I have to micromanage dependencies myself because you don't know how to do it.

Seriously?

And very often, one of these dependencies is a function that I created. Because you don't make the difference between a variable and a function, I have to tell you, using useCallback, that you shouldn't rerender for anything. Same consequence, same final cryptic signature:

const handleClick = useCallback(
    async event => {
        event.persist();
        const type =
            typeof rowClick === 'function'
                ? await rowClick(id, resource, record)
                : rowClick;
        if (type === false || type == null) {
            return;
        }
        if (['edit', 'show'].includes(type)) {
            navigate(createPath({ resource, id, type }));
            return;
        }
        if (type === 'expand') {
            handleToggleExpand(event);
            return;
        }
        if (type === 'toggleSelection') {
            handleToggleSelection(event);
            return;
        }
        navigate(type);
    },
    [
        // oh god, please no
        rowClick,
        id,
        resource,
        record,
        navigate,
        createPath,
        handleToggleExpand,
        handleToggleSelection,
    ],
);

A simple component with a few event handlers and lifecycle callbacks becomes a pile of gibberish code just because I have to manage this dependency hell. All that is because you've decided that a component may execute an arbitrary number of times.

So for example, if I want to make a counter that increases every second and every time the user clicks on a button, I have to do this:

function Counter() {
    const [count, setCount] = useState(0);

    const handleClick = useCallback(() => {
        setCount(count => count + 1);
    }, [setCount]);

    useEffect(() => {
        const id = setInterval(() => {
            setCount(count => count + 1);
        }, 1000);
        return () => clearInterval(id);
    }, [setCount]);

    useEffect(() => {
        console.log('The count is now', count);
    }, [count]);

    return <button onClick={handleClick}>Click Me</button>;
}

While if you knew how to keep track of dependencies, I could simply write:

function Counter() {
    const [count, setCount] = createSignal(0);

    const handleClick = () => setCount(count() + 1);

    const timer = setInterval(() => setCount(count() + 1), 1000);

    onCleanup(() => clearInterval(timer));

    createEffect(() => {
        console.log('The count is now', count());
    });

    return <button onClick={handleClick}>Click Me</button>;
}

That's valid Solid.js code, by the way.

Mind blown

Finally, using useEffect wisely requires reading a 53 pages dissertation. I must say, that is a terrific piece of documentation. But if a library requires me to go through dozens of pages to use it properly, isn't it a sign that it's not well designed?

Makeup Your Mind

Since we've already talked about the leaky abstraction that is useEffect, you've tried to improve it. You've introduced me to useEvent, useInsertionEffect, useDeferredValue, useSyncWithExternalStore, and other gimmicks.

And they do make you look beautiful:

function subscribe(callback) {
    window.addEventListener('online', callback);
    window.addEventListener('offline', callback);
    return () => {
        window.removeEventListener('online', callback);
        window.removeEventListener('offline', callback);
    };
}

function useOnlineStatus() {
    return useSyncExternalStore(
        subscribe, // React won't resubscribe for as long as you pass the same function
        () => navigator.onLine, // How to get the value on the client
        () => true, // How to get the value on the server
    );
}

But to me, this is lipstick on a pig. If reactive effects were easier to use, you wouldn't need all these other hooks.

To put it otherwise: you have no other solution than to grow the core API more and more over time. For people like me, who have to maintain huge codebases, this constant API inflation is a nightmare. Seing you wear more and more makeup everyday is a constant reminder of what you're trying to hide.

Strict Machine

Your hooks are a great idea, but they come at a cost. And this cost is the Rules of Hooks. They aren't easy to memorize, they aren't easy to put into practice. But they force me to spend time on code that shouldn't need it.

For instance, I have an "inspector" component that can be dragged around by the end user. Users can also hide the inspector. When hidden, the inspector component renders nothing. So I would very much like to "leave early", and avoid registering event listeners for nothing.

const Inspector = ({ isVisible }) => {
    if (!isVisible) {
        // leave early
        return null;
    }
    useEffect(() => {
        // Register event listeners
        return () => {
            // Unregister event listeners
        };
    }, []);
    return <div>...</div>;
};

But no, that's against the Rules of Hooks, as the useEffect hook may or may not be executed depending on props. Instead, I have to add a condition to all effects so that they leave early when the isVisible prop is false:

const Inspector = ({ isVisible }) => {
    useEffect(() => {
        if (!isVisible) {
            return;
        }
        // Register event listeners
        return () => {
            // Unregister event listeners
        };
    }, [isVisible]);

    if (!isVisible) {
        // leave not so early
        return null;
    }
    return <div>...</div>;
};

As a consequence, all the effects will have the isVisible prop in their dependencies and potentially run too often (which can harm performance). I know, I should create an intermediate component that just rendrs nothing if isVisible is false. But why? This is only one example of the Rules of Hooks getting in my way - there are many others. One consequence is that a significant portion of the code of my React codebases is spent satisfying the Rules of Hooks.

The Rules of Hooks are a consequence of implementation detail - the implementation you chose for your hooks. But it doesn't have to be like that.

You've Been Gone Too Long

You've been around since 2013, and you've made a point of keeping backward compatibility as long as possible. And I thank you for that - that's one of the reasons why I've been able to build a huge codebase with you. But this backward compatibility comes at a cost: documentation and community resources are, at best outdated, at worst, misleading.

For instance, when I search for "React mouse position" on StackOverflow, the first result suggests this solution, which was already outdated React a century ago:

class ContextMenu extends React.Component {
    state = {
        visible: false,
    };

    render() {
        return (
            <canvas
                ref="canvas"
                className="DrawReflect"
                onMouseDown={this.startDrawing}
            />
        );
    }

    startDrawing(e) {
        console.log(
            e.clientX - e.target.offsetLeft,
            e.clientY - e.target.offsetTop,
        );
    }

    drawPen(cursorX, cursorY) {
        // Just for showing drawing information in a label
        this.context.updateDrawInfo({
            cursorX: cursorX,
            cursorY: cursorY,
            drawingNow: true,
        });

        // Draw something
        const canvas = this.refs.canvas;
        const canvasContext = canvas.getContext('2d');
        canvasContext.beginPath();
        canvasContext.arc(
            cursorX,
            cursorY /* start position */,
            1 /* radius */,
            0 /* start angle */,
            2 * Math.PI /* end angle */,
        );
        canvasContext.stroke();
    }
}

yuck

When I look for an npm package for a particular React feature, I mostly find abandoned packages with old, outdated syntax. Take react-draggable for instance. It's the de facto standard for implementing drag and drop with React. It has many open issues, and low development activity. Perhaps it's because it's still class components based - it's hard to attract contributors when the codebase is so old.

As for your official docs, they still recommend using componentDidMount and componentWillUnmount instead of useEffect. The core team has been working on a new version, called Beta docs, for the last two years. They're still not ready for prime time.

All in all, the loooong migration to hooks is still not finished, and it has produced a notable fragmentation in the community. New developers struggle to find their way in the React ecosystem, and old developers strive to keep up with the latest developments.

Family Affair

At first, your father Facebook looked super cool. Facebook wanted to "Bring people closer together" - count me in! Whenever I visited your parents, I met new friends.

But then things got messy. Your parents enrolled in a crowd manipulation scheme. They invented the concept of "Fake News". They started keeping files on everyone, without their consent. Visiting your parents became scary - to the point that I've deleted my own Facebook account a few years ago.

I know - you can't hold children accountable for the actions of their parents. But you still live with them. They fund your development. They're your biggest users. You depend on them. If one day, they fall because of their behavior, you'll fall with them.

Other major JS frameworks have been able to break free from their parents. They became independent and joined a foundation called The OpenJS Foundation. Node.js, Electron, webpack, lodash, eslint, and even Jest are now funded by a collective of companies and individuals. Since they can, you can, too. But you don't. You're stuck with your parents. Why?

Vernon

It's Not Me, It's You

You and I have the same purpose in life: to help developers build better UIs. I'm doing it with React-admin. So I understand your challenges, and the tradeoffs you have to make. Your job is not an easy one, and you're probably solving tons of problems I even have no idea of.

But I find myself constantly trying to hide your flaws. When I talk about you, I never mention the issues above - I just pretend we're a great couple, with no clouds on the horizon. In react-admin, I introduce APIs that remove the hassle of dealing with you directly. And when people complain about react-admin, I do my best to address their problem - when most of the time, they have a problem with you. Being a framework developer, I'm also on the front line. I get all the problems before everyone else.

I've looked at other frameworks. They have their own flaws - Svelte is not JavaScript, SolidJS has nasty traps like:

// this works in SolidJS
const BlueText = props => <span style="color: blue">{props.text}</span>;

// this doesn't work in SolidJS
const BlueText = ({ text }) => <span style="color: blue">{text}</span>;

But they don't have your flaws. The flaws that make me want to cry sometimes. The flaws that become so annoying after years of dealing with them. The flaws that make me want to try something else. In comparison, all other frameworks are refreshing.

I Can't Quit You Baby

The problem is that I can't leave you.

First, I love your friends. MUI, Remix, react-query, react-testing-library, react-table... When I'm with those guys, I always do amazing things. They make me a better developer - they make me a better person. I can't leave you without leaving them.

It's the ecosystem, stupid.

I can't deny that you have the best community and the best third-party modules. But honestly, it's a pity that developers choose you not for your qualities, but for the qualities of your ecosystem.

Second, I've invested too much in you. I've built a huge codebase with you that can't possibly be migrated to another framework without turning crazy. I've built a business around you that lets me develop open-source software in a sustainable way.

I depend on you.

Call Me Maybe

I've been very transparent about my feelings. Now I'd like you to do the same. Do you plan to address the points I've listed above, and if so, when? How do you feel about library developers like me? Should I forget about you, and move on to something else? Or should we stay together, and work on our relationship?

What's next for us? You tell me.

Hi

Edit 2022-09-20: This blew up on Hacker News and Reddit. Feel free to comment here or there.

And if you're a first-time visitor of this blog, we publish content roughly every week. Some articles are pretty popular. So subscribe to it via RSS, or using our Twitter account @marmelab.

Finally, if you've made it this far, you probably want to know more about what I do. Check out:

  • react-admin, an open-source framework for B2B apps (20,000 stars on GitHub)
  • GreenFrame, a tool to measure and reduce the carbon footprint of web apps

Edit 2022-09-21: React has called! Check their response on Twitter.

Did you like this article? Share it!