⚡ LIVE From Lizard to Wizard workshop · April 27 – 30 Pick your date →

· 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, 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.

Share:
10 React tips I wish someone had told me before I mass-produced bugs

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!

🏆 SOLD OUT IN SINGAPORE · ATHENS · LONDON

From Lizard to Wizard

4 hours. Algorithms, system design, security, observability, AI.
The workshop that turns mid engineers into senior ones.

€250 4-HOUR INTENSIVE
Pick your date →

Spots are vanishing. Don't be the one who waited.

    Share:

    🎙️ Latest Podcast Episodes

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

    Database Performance at Scale with Tyler Benfield
    Episode 30
    58 minutes

    Señors @ Scale host Neciu Dan sits down with Tyler Benfield, Staff Software Engineer at Prisma, to go deep on database performance. Tyler's path into databases started at Penske Racing, writing trackside software for NASCAR pit stops, and eventually led him into query optimization, connection pooling, and building Prisma Postgres from scratch. From the most common ORM anti-patterns to scaling Postgres on bare metal with memory snapshots, this is the database conversation most frontend developers never get.

    📖 Read Takeaways
    Open Source at Scale with Corbin Crutchley
    Episode 29
    52 minutes

    Señors @ Scale host Neciu Dan sits down with Corbin Crutchley — lead maintainer of TanStack Form, Microsoft MVP, VP of Engineering, and author of a free book that teaches React, Angular, and Vue simultaneously — to dig into what it actually means to maintain a library that gets a million downloads a week. Corbin covers the origin of TanStack Form, why versioning is a social contract, what nearly made him quit open source, and the surprisingly non-technical path that got him into a VP role.

    📖 Read Takeaways
    PostCSS, AutoPrefixer & Open Source at Scale with Andrey Sitnik
    Episode 28
    58 minutes

    Señors @ Scale host Neciu Dan sits down with Andrey Sitnik — creator of PostCSS, AutoPrefixer, and Browserslist, and Lead Engineer at Evil Martians — to explore how one developer became responsible for 0.7% of all npm downloads. Andrey shares the discrimination story that drove AutoPrefixer, the open pledge that forced PostCSS 8 to ship, and why the Mythical Man-Month applies directly to LLM agent coordination.

    📖 Read Takeaways
    React Server Components at Scale with Aurora Scharff
    Episode 27
    52 minutes

    Señors @ Scale host Neciu Dan sits down with Aurora Scharff — Senior Consultant at Creon Consulting, Microsoft MVP in Web Technologies, and React Certifications Lead at certificates.dev — to explore the real mental model shift required to understand React Server Components. Aurora shares her path from Robotics to frontend, what it was like building a controller UI for Boston Dynamics' Spot robot dog in React, and why the ecosystem finally feels like it's stabilizing.

    📖 Read Takeaways
    Back to Blog