React Without useEffect
useEffect
is the hardest part of React - and the most error-prone. Getting a complete understanding of useEffect
takes a 49 minutes read. I've already explained my concerns about useEffect
in another article: In my opinion, useEffect
is the biggest pain point of React.
But what if we could build entire React apps without useEffect
?
A Complete React App Without useEffect
Check out this Help Desk application. It's a fully-functional React app that does all the things you'd expect, from ticket search to multi-agent collaboration.
It does data fetching, syncs the list filters with the URL, stores user preferences in localStorage, shows real-time notifications, manages content locks, updates data when another user does a mutation, and more. All without useEffect
(check the source). And this is true React - not Solid.js or Svelte. So how does it work?
Of course, there is a trick: this application uses a React framework - in that case, react-admin. The primary function of React frameworks is to make React easier to use for specific use cases. For React-admin, it's for admin and B2B single-page apps.
My point is: if you want to build a React app, you should use a framework. Let's see why.
Declarative Data Fetching
Take the customer column on the messages list. It has to fetch the customer name for each ticket. How would you write it in pure React?
Here is a basic React implementation:
const CustomerNameField = ({ record }) => {
const [customer, setCustomer] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`${API_URL}/customers/${record.customer_id}`)
.then(response => {
setCustomer(response.data);
setLoading(false);
})
.catch(error => {
setError(error);
setLoading(false);
});
}, [record.customer_id, setCustomer, setLoading, setError]);
if (loading) {
return <Loading />;
}
if (error) {
return <Error />;
}
if (!customer) {
return null;
}
return <span>{customer.name}</span>;
};
If you look at the code of the HelpDesk app, you will see that the actual CustomerNameField
component is actually much simpler:
const CustomerNameField = () => (
<ReferenceField reference="customers" source="customer_id" />
);
This JSX code says "display the related customer whose id is in the customer_id
field". It's a declarative syntax, which means that you ask the program what you want to display, and not how to fetch and render the data.
Under the hood, react-admin's <ReferenceField>
component does use useEffect
for data fetching, but that's an implementation detail that you don't need to worry about.
And <ReferenceField>
actually does way more than the initial code. When used in a data table, it aggregates and deduplicates the calls for all the customers in the table. It then sends a single request to the API, with a WHERE IN
clause.
Declarative Data Sync
Now, take the lock system. When another user locks a ticket, the current user sees the ticket as locked, and cannot post a reply.
To implement that in pure react, the <TicketActivity>
component has to fetch the lock on mount, subscribe to the lock changes, update the lock state when the lock changes, and unsubscribe when the component is unmounted:
const TicketActivity = ({ record }) => {
const [lock, setLock] = useState(null);
useEffect(() => {
setLoading(true);
fetch(`${API_URL}/locks/tickets/${record.id}`).then(response => {
setLock(response.data);
});
}, [record.id, setLock, setLoading, setError]);
useEffect(() => {
const subscription = subscribeTo(
`locks/tickets/${record.id}`,
event => {
if (event.data.type === 'locked') {
setLock(event.data.payload);
}
if (event.data.type === 'unlocked') {
setLock();
}
},
);
return () => subscription.unsubscribe();
}, [record.id, setLock]);
if (!lock) return null;
return (
<Typography variant="body2">
Locked by
<ReferenceField
record={lock}
source="identity"
reference="agents"
/>
</Typography>
);
};
That's two useEffect
calls, and a lot of code - I've even left out the loading/error states. Now look at the <TicketActivity>
implementation in the HelpDesk app. It looks like this:
const TicketActivity = () => {
const { data: lock } = useGetLockLive('tickets');
if (!lock) return null;
return (
<Typography variant="body2">
Locked by
<ReferenceField
record={lock}
source="identity"
reference="agents"
/>
</Typography>
);
};
The useGetLockLive
hook says: "I want to get the lock for the current ticket, and I want it to be refreshed in real-time when the lock changes in the backend". Again, it's declarative: how this is done is not important, or rather, it's left to react-admin.
This time, react-admin replaces calls to useEffect
not by a component, but by another custom hook. Yet this hook has no dependency array, and the developer doesn't have to worry about rerenders, cleanup on unmount, and all that plumbing. The custom hook hides the complexity behind a task-oriented API.
The Help Desk app contains tons of components that would normally require many useEffect
calls, but that use higher-level, business-oriented APIs. One exception: the useAddReadToTicket
hook uses useAsyncEffect
to create a new record on mount. This hook is a useEffect
wrapper that allows asynchronous code (and is explained in a previous post). But that's it.
But That Doesn't Work In My Case
You may say: "That declarative syntax works for simple cases, but I often have to use useEffect
and I see no way around it". Well, we've built many React apps (including an e-commerce admin and a CRM) that don't call useEffect
even once. So yes, it's possible.
There is a price to pay, of course: You have to learn The Framework Way™ to handle relationships, search & filtering, roles & permissions, Pub/Sub, user preferences, etc. instead of doing it your way. Before becoming more efficient, you'll have to develop at a slower pace.
And sometimes, the framework gets in the way and doesn't let you do exactly what you want. In those cases, you'll fall back to pure React, and you'll probably have to use useEffect
directly. I do too, sometimes, and the pain it brings makes me wish react-admin had a declarative API for that use case. Using a framework greatly reduces the amount of imperative code you have to write, but it doesn't eliminate it.
Conclusion
React is supposed to favor declarative code over imperative code. But since its API is tiny, many features require imperative boilerplate, with the help of useEffect
. It's as if React were missing a layer to really let developers write declarative code in most use cases.
Libraries and frameworks fill that gap. React-query, react-hook-form, react-router, MUI, and emotion use useEffect
so that you don't have to. React-admin builds up on these libraries, and exposes higher-level components and hooks to let you build complex, declarative React apps free of useEffect
.
Like a CRUD app in 13 lines of code:
import React from 'react';
import { Admin, Resource, ListGuesser } from 'react-admin';
import simpleRestProvider from 'ra-data-simple-rest';
const dataProvider = simpleRestProvider('https://domain.tld/api');
const App = () => (
<Admin dataProvider={dataProvider}>
<Resource name="users" list={ListGuesser} />
</Admin>
);
export default App;
Whether you are a new React developer or a seasoned one, useEffect
is probably your biggest productivity killer. Don't let it get in the way of your creativity. Use a React framework. And if you don't know which one to choose, give react-admin a try.