Skip to content

· 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

Neciu Dan

Hi there, it's Dan, Senior Engineering Manager at Perk, host of Señors at Scale - a podcast for Senior Engineers, Organizer of ReactJS Barcelona meetup, and international speaker, 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.

Share:

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!

Neciu Dan

Discover more from The Neciu Dan Newsletter

A weekly column on Tech & Education, startup building and occasional hot takes.

Over 1,000 subscribers

🎙️ Latest Podcast Episodes

Dive deeper with conversations from senior engineers about scaling applications, teams, and careers.

Hypermedia at Scale with Carson Gross
Episode 53
58 minutes

Señors @ Scale host Neciu Dan sits down with Carson Gross, creator of HTMX, principal at Big Sky Software, and computer science professor at Montana State University. Carson has been programming for more than 30 years, from HyperCard on a family Mac to Excel macros for an industrial engineering firm, a JVM language called Gosu, a Rails startup, and the 2012 library Intercooler.js that he rewrote without jQuery during COVID and released as HTMX. His argument is that the web was built as a hypermedia system, that links and forms are control logic living inside the document, and that most of what we call REST today fails the one constraint Roy Fielding cared about. He also covers which apps HTMX is wrong for, what changed in HTMX 4 and why 3 was skipped, and why his students now sit pencil and paper exams. It is a conversation about using the browser as the hypermedia client it was built to be.

📖 Read Takeaways
Performance at Scale with Ryan Townsend
Episode 52
52 minutes

Señors @ Scale host Neciu Dan sits down with Ryan Townsend, Principal Product Manager for Performance at Cloudflare, where he owns real user monitoring and the wider performance measurement remit. Ryan has over 20 years in web technology and a decade as a CTO: he co-founded Shift Commerce, the first enterprise scale headless SaaS, and was CTO of Matalan Direct after the business he built came second in Shopify's Build a Business competition with only three months of trading. We cover why the 'Rails doesn't scale' story was always a myth, the resume driven development he kept finding as a fractional CTO, the personalization script that claimed zero performance impact and cost seconds at P90, and how Shift ran over 99% cache hit ratios by splitting the anonymous page from the basket.

📖 Read Takeaways
Design Systems at Scale with Szymon Chudy
Episode 51
56 minutes

Señors @ Scale host Neciu Dan sits down with Szymon Chudy, Software Engineer at Netflix on the Hawkins team, which builds the design system behind thousands of internal applications used to produce the shows you watch. Before Netflix, Szymon spent two and a half years at Lokalise, growing from senior to staff frontend engineer and leading frontend direction across teams, and before that he toured Poland as a reggae guitarist while freelancing as a web developer in the off season. From why the source of truth for a design system lives in code, to Hawkins Consumer versus Hawkins Professional, to the UI paved road that changes what freedom and responsibility mean at Netflix, to measuring the team on adoption rate and shipping codemods and agent skills for every breaking change, to what being an informed captain who farms for dissent means in practice, this is the design system conversation for anyone maintaining a platform other engineers have to choose to use.

📖 Read Takeaways
Agentic Systems at Scale with Vilva Athiban
Episode 50
52 minutes

Señors @ Scale host Neciu Dan sits down with Vilva Athiban, Lead AI Engineer at Omio, one of Europe's leading travel platforms, where he architects and ships agentic AI systems in production. Vilva designed and delivered Omio AI, along with the MCP integrations that connect AI agents to the company's systems. He never set out to switch from JavaScript to AI: in June 2024 Omio picked two people to start an AI team reporting to the CEO and CTO, and he learned on the job, from his first API call to an LLM up to a consumer-facing agent whose backend runs on nothing but MCP. From why a bigger model with medium thinking came out cheaper than the mini, to capping tool calls because LLMs are built for token maxing, to the whale hunting request that showed his team guardrails are subjective, this is a conversation about what production agentic systems look like once the tutorials run out.

📖 Read Takeaways
Back to Blog