· react · 11 min read
10 React tips I wish someone had told me before I mass-produced bugs
After running a 30-day React deep-dive, these are the 10 patterns that changed how I write components, manage state, and think about performance.
Neciu Dan
Hi there, it's Dan, a technical co-founder of an ed-tech startup, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, international speaker and Staff Software Engineer, I'm here to share insights on combining
technology and education to solve real problems.
I write about startup challenges, tech innovations, and the Frontend Development.
Subscribe to join me on this journey of transforming education through technology. Want to discuss
Tech, Frontend or Startup life? Let's connect.
Last quarter, I got pulled into a code review for a component I’d never touched.
About 400 lines, a dozen useState hooks at the top, three useEffects that depended on each other in ways that weren’t obvious, and a useMemo wrapping a string concatenation.
I left nine comments. Four of them were things I’d gotten wrong myself at some point.
That review turned into a conversation with the team about React patterns we keep getting burned by.
The same handful of mistakes, in different codebases, by developers at every experience level. I started writing them down, composed a daily newsletter with tips, and now have condensed the best ones (the ones people liked and said helped them) into this article.
Enjoy!
When state lies, hire a reducer
I reviewed a data-fetching component at work that had three useState hooks at the top. Loading, error, data.
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [post, setPost] = useState(null);
When the fetch succeeded, it called three setters. Somebody forgot to clear the error on success. So the component was simultaneously not loading, showing an error, AND rendering data.
The UI was lying to the user. And nobody noticed for weeks.
This is where useReducer earns its keep. Instead of hoping you remember to call three setters in the right order, you describe what happened and let the reducer figure out the next state.
function fetchReducer(state, action) {
switch (action.type) {
case 'FETCH_SUCCESS':
return { isLoading: false, error: null, post: action.payload };
case 'FETCH_ERROR':
return { isLoading: false, error: 'Something went wrong!', post: null };
default:
return state;
}
}
// Wire it up
const [state, dispatch] = useReducer(fetchReducer, {
isLoading: true,
error: null,
post: null,
});
// Then in your fetch handler:
dispatch({ type: 'FETCH_SUCCESS', payload: data });
One dispatch, one guaranteed valid state. You can’t accidentally end up in an impossible combination because the reducer won’t let you.
The rule of thumb isn’t about how many useStates you have. It’s about how entangled they are.
useTransition for rendering, debounce for network
I used to reach for debounce any time the UI felt laggy.
Then I learned about useTransition and slapped it everywhere instead. That was also wrong. (I have a talent for swapping one wrong approach for another.)
Here’s the distinction: useTransition is for CPU-bound rendering work. You have a huge list in memory, and filtering it causes React to choke on the re-render.
debounce is for network-bound work. You’re hitting an API endpoint, and you don’t want to fire a request on every keystroke.
If the filtering happens client-side, useTransition is your tool:
const [query, setQuery] = useState('');
const [filteredItems, setFilteredItems] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
// Update the input immediately
setQuery(e.target.value);
// Defer the expensive re-render
startTransition(() => {
setFilteredItems(filterItems(e.target.value));
});
};
The input stays responsive because setQuery is high-priority. The filtering happens in the background, and if the user keeps typing, React discards the stale render and starts over.
If the filtering hits an API? Debounce the fetch. useTransition won’t help you there because the bottleneck is the network, not the render.
State colocation
I am ashamed to admit how long it took me to internalize this one.
At a previous company, we had a dashboard with a search bar and an analytics chart sitting side by side. Every keystroke in the search re-rendered the chart. The chart had a lot of SVG nodes, and you could feel it.
My first instinct was React.memo on the chart. It worked, but there was a simpler fix I should have tried first.
// The search state lives in the parent. Everything re-renders.
function Dashboard() {
const [searchTerm, setSearchTerm] = useState('');
return (
<div>
<SearchBox value={searchTerm} onChange={setSearchTerm} />
<SearchResults query={searchTerm} />
<AnalyticsChart /> {/* Re-renders on every keystroke! */}
</div>
);
}
When you call setSearchTerm, React re-renders Dashboard and all its children. AnalyticsChart doesn’t use searchTerm, but React doesn’t know that. It re-renders anyway because its parent re-rendered.
To fix this, we move the state into a wrapper component that only contains the things that actually need it.
function Dashboard() {
return (
<div>
<SearchFeature />
<AnalyticsChart /> {/* Parent didn't re-render, so neither does this */}
</div>
);
}
function SearchFeature() {
const [searchTerm, setSearchTerm] = useState('');
return (
<>
<SearchBox value={searchTerm} onChange={setSearchTerm} />
<SearchResults query={searchTerm} />
</>
);
}
Now setSearchTerm re-renders SearchFeature, not Dashboard. The chart is a sibling, not a child, so it’s untouched.
Before you reach for React.memo, check if the state can just move down the tree.
useEffect is synchronization
I spent a late night fighting a useEffect that kept firing when I was sure it shouldn’t. Dependencies were set, there were no infinite loops, and the logic was scoped.
I was convinced React was broken.
It wasn’t. (It never is.)
The problem was that I was thinking of useEffect like componentDidMount. “Run this once when the component appears.”
But useEffect synchronizes a side effect with reactive values. “Keep this in sync with these dependencies.” Different mental model entirely.
Once that clicked, I started seeing useEffect misuse everywhere, including in my own code.
// This is NOT data fetching. This is a mess.
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]);
// This belongs in a library that manages caching,
// deduplication, and race conditions for you.
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
The raw useEffect version has no cancellation, no caching, and no handling of the component unmounting mid-fetch.
React Query (or SWR, or whatever you prefer) handles all of that.
If your effect fetches data, synchronizes with localStorage, or subscribes to a browser API, it’s probably fine.
If it’s deriving a state from another state, it shouldn’t be an effect at all.
The best documentation article I’ve ever read is “You might not need an effect” by the React team.
Stop using index as your key
That console warning about unique keys appears, and the immediate fix is key={index}. I did it for years without thinking about it.
Then I shipped a bug where users typed notes into a list, deleted the first item, and their text jumped to the wrong row.
The support ticket was colorful. (The user attached a screen recording and everything.)
const ListItem = ({ item }) => (
<li>
{item.text} <input placeholder="Type something..." />
</li>
);
Type something into the first input. Remove the first item. With key={index}, the text you typed is still there, sitting next to the wrong item.
React didn’t know you removed the first item. It just saw the list got shorter, kept the same DOM nodes, and shuffled the data around.
With key={item.id}, React removes the correct DOM node, and the input state goes with it.
Use a stable, unique ID from your data. If your data doesn’t have IDs, that’s a data modeling problem worth fixing upstream.
The key prop resets everything
Speaking about the key prop, it’s not just for lists; you can actually put it on any component, and when the key changes, React throws away the old instance and mounts a brand new one.
I discovered this while building a settings panel (the kind with tabs for different users). The state from the previous user was leaking into the next one. I had a useEffect that watched the userId prop to reset the form, but it kept getting out of sync.
I removed the useEffect and added a single prop.
function SettingsPanel() {
const [userId, setUserId] = useState(1);
return (
<div>
<UserTabs onChange={setUserId} />
{/* When userId changes, React unmounts the old form and mounts a fresh one */}
<UserSettingsForm key={userId} userId={userId} />
</div>
);
}
function UserSettingsForm({ userId }) {
const { data: user } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
const [name, setName] = useState('');
// No useEffect to reset the form. The key change does this.
return <input value={name} onChange={e => setName(e.target.value)} />;
}
When userId changes, React unmounts the old UserSettingsForm entirely and mounts a new one.
The state resets, the query re-runs, and the useEffect I spent an hour debugging just doesn’t need to exist anymore.
Your useMemo is overhead
I went through a phase where I wrapped everything in useMemo. Strings, booleans, simple arithmetic.
If it computed a value, I memoized it. I thought I was being responsible.
I was adding overhead.
// I actually wrote this in production code once
const fullName = useMemo(
() => `${user.firstName} ${user.lastName}`,
[user.firstName, user.lastName]
);
// The non-embarrassing version
const fullName = `${user.firstName} ${user.lastName}`;
useMemo isn’t free. On every render, React calls the hook, shallow-comparisons every dependency, and decides whether to return the cached value or recompute.
For a string concatenation, that ceremony costs more than the concatenation itself.
Profile before you memoize.
If you’re on React 19, the React Compiler already handles memoization automatically at build time. It inserts useMemo and useCallback where they actually help, so the less you do it manually, the cleaner their job is.
SRP means “one reason to change.”
I used to think the Single Responsibility Principle meant a component should “do one thing.” So I’d look at a UserProfile that fetched data, handled loading, managed errors, and rendered a card, and think: “It does one thing. It shows a user profile.”
But it has four reasons to change. The API contract could change. The loading UX could change. The error handling requirements could change. The card layout could change.
Four different people on my team could need to edit this file for four unrelated reasons.
// Data fetching logic, isolated
const useUserData = (userId) => {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
});
return { user, isLoading, error };
};
// Presentation, isolated
const UserProfile = ({ userId }) => {
const { user, isLoading, error } = useUserData(userId);
if (isLoading) return <p>Loading...</p>;
if (error) return <p>Something went wrong!</p>;
return <h1>Welcome, {user.name}</h1>;
};
The hook changes when the API changes. The component changes when the layout changes. They don’t step on each other.
useLayoutEffect kills the flicker
I built a tooltip component that measured the trigger button’s position and placed the tooltip below it.
The positioning logic lived in useEffect, and it worked. Mostly.
Except for that one-frame flash where the tooltip appeared at top: 0 before jumping into place.
Here’s why.
useEffect runs after the browser paints. So the sequence is: React renders the tooltip in the wrong spot, the browser shows it to the user, THEN the effect runs and fixes the position.
For one frame, the tooltip is in the wrong place.
// The flickery version
useEffect(() => {
if (buttonRef.current && tooltipRef.current) {
const { bottom } = buttonRef.current.getBoundingClientRect();
tooltipRef.current.style.top = `${bottom + 10}px`;
}
}, []);
Swapping to useLayoutEffect fixes it because it runs after React updates the DOM but before the browser paints.
// No flicker
useLayoutEffect(() => {
if (buttonRef.current && tooltipRef.current) {
const { bottom } = buttonRef.current.getBoundingClientRect();
tooltipRef.current.style.top = `${bottom + 10}px`;
}
}, []);
The tooltip is in the right position from the very first frame.
Fair warning: useLayoutEffect blocks the paint. 99% of the time, you want useEffect. But for measuring the DOM and immediately mutating styles, you can use it.
Compound Components over prop soup
<Accordion items={items} renderHeader={...} renderBody={...} onToggle={...} />
I’ve built this component. You probably have too.
It takes a data array and a pile of render props, and it works right up until someone needs to put a custom icon in the third accordion header, but not the others.
The Compound Components pattern flips the API inside out.
import { createContext, useContext, useState } from 'react';
const AccordionItemContext = createContext(null);
function Accordion({ children }) {
return <div className="accordion">{children}</div>;
}
function Item({ children }) {
const [isOpen, setIsOpen] = useState(false);
return (
<AccordionItemContext.Provider value={{ isOpen, setIsOpen }}>
<div className="accordion-item">{children}</div>
</AccordionItemContext.Provider>
);
}
function Header({ children }) {
const { setIsOpen } = useContext(AccordionItemContext);
return (
<div onClick={() => setIsOpen(open => !open)}>
{children}
</div>
);
}
function Body({ children }) {
const { isOpen } = useContext(AccordionItemContext);
return isOpen ? <div>{children}</div> : null;
}
Accordion.Item = Item;
Accordion.Header = Header;
Accordion.Body = Body;
Each Item creates its own Context provider. When Header calls useContext, React walks up the tree and finds the nearest provider, which is always its own parent Item.
And the API looks like this:
<Accordion>
<Accordion.Item>
<Accordion.Header>Is this flexible?</Accordion.Header>
<Accordion.Body>You can put whatever you want in here.</Accordion.Body>
</Accordion.Item>
</Accordion>
React skills
These 10 tips came from my Daily React program, 30 days of React lessons with all the nuance, and the 20 other tips I didn’t fit here.
I also turned them into a Claude Code skill you can install in two commands:
/plugin marketplace add Cst2989/react-tips-skill
/plugin install react-tips@neciudan.dev
And the main takeaways are:
- Keep state local
- Use the key correctly
- You don’t need useEffect
You’re welcome!