https://marmelab.com/blog/2022/09/20/react-i-love-you.html
This app works best with JavaScript enabled.
[X8wPEPoV4g][tf2K8MvAb4]
HomeValuesShowcaseJobsBlog
en
/
fr
Contact us
[HxBkQd231U][large]
React I Love You, But You're Bringing Me Down
Francois Zaninotto
Francois ZaninottoSeptember 20, 2022
#react
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 (
{a} + {b} = {a + b}
);
};
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:
{a} + {b} = {a + b}
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 (
{children}
);
};
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 } }) => (
{isDirty &&
This field is dirty
}
),
(prevProps, nextProps) =>
prevProps.formState.isDirty === nextProps.formState.isDirty,
);
export const NestedInputContainer = ({ children }) => {
const methods = useFormContext();
return ;
};
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 =>
Hello, {props.name}!
;
But no, that would be too easy. Instead, you've invented this
react.forwardRef abomination:
const MyComponent = React.forwardRef((props, ref) => (
Hello, {props.name}!
));
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 = (props: ) => (
Hello, {props.name}!
);
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 ;
}
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 ;
}
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
...
;
};
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
...
;
};
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 (
);
}
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 => {props.text};
// this doesn't work in SolidJS
const BlueText = ({ text }) => {text};
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
Did you like this article? Share it!
[wA46l]
Marmelab 2013-2022. All rights reserved
4 rue Girardet, 54 000 Nancy, FRANCE
Legal Mentions