Skip to content
🛡️ FREE Master Frontend Security · All 7 modules live · 100% free Start free →

· 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:

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!

🛡️ FRONTEND SECURITY · REACT · VUE · ANGULAR · VANILLA JS

Master Security in Frontend Applications

Free, comprehensive frontend security course.
XSS, CSRF, AI security, broken access control & the vulnerabilities that actually get you breached.

100% FREE 7 MODULES · ALL LIVE
Start learning free →

All 7 modules live now. No credit card.

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.

Accessibility at Scale with Craig Abbott
Episode 48
59 minutes

Señors @ Scale host Neciu Dan sits down with Craig Abbott, Principal Accessibility Specialist at TetraLogical and the former Head of Accessibility at the UK's Department for Work and Pensions, one of the largest government departments in the country, where he built a dedicated accessibility practice from nothing and open sourced the DWP Accessibility Manual. Craig has over 15 years in user centred design and has led accessibility work across the public sector and at Elastic. From what sustainable accessibility actually means and why third party audits alone don't get you there, to the three C's of compliance, culture and capability, to running screen readers in VMs without expensive licences, to accessibility acceptance tests in CI with Playwright, Cucumber and Guidepup, to why compliance does not mean usable, this is the accessibility conversation for teams who want it to survive the person who cares about it.

📖 Read Takeaways
Versatility at Scale with Carmen Huidobro
Episode 47
42 minutes

Señors @ Scale host Neciu Dan sits down with Carmen Huidobro, CTO at Incredible Bee in Vienna, where she builds products and helps teams figure out what should be automated and what should stay in the hands of users. Carmen has spent 17 years in tech, almost all of it freelancing, working across Objective-C, Ruby on Rails, the web, mobile, hardware, and even ABAP inside an SAP consultancy, plus five years in developer relations and developer education. Her argument is that the generalist versus specialist debate misses the point: the durable skill is being an expert at adapting. From adding a local Mistral model to a twenty-year-old macOS app without betraying the people who use it, to the refugee hackathon project the City of Vienna still runs a decade later, to why she won't take money from junior developers, this is a conversation about the skills that survive the shift.

📖 Read Takeaways
CI/CD at Scale with Marko Gacesa
Episode 46
45 minutes

Señors @ Scale host Neciu Dan sits down with Marko Gaćeša, Head of Product at Semaphore, the agent-native CI/CD platform, and the first product guest on the show. Marko is a serial entrepreneur whose career spans developer tools, IoT, industrial automation and enterprise SaaS, including founding Dry Tools and serving as Chief Product Officer at Alchemy Cloud, with earlier work in domains like medical equipment where quality is non-negotiable. From why testing rather than coding is now the bottleneck, to what agent-native CI/CD actually means when developers live inside their coding agent, to how SemAI attacks flaky tests and automates migration off GitHub Actions, to pricing a platform where a minute of CI is not the same minute everywhere, this is the product side of developer tooling from someone shipping it.

📖 Read Takeaways
AI Harness at Scale with Maxim Salnikov
Episode 45
44 minutes

Señors @ Scale host Neciu Dan sits down with Maxim Salnikov, AI Dev Tools Solution Engineer at Microsoft, where he leads AI native development enablement for over 100 enterprise customers of Microsoft and GitHub in Norway. Maxim has been building for the web since the late 90s and spends his days inside real enterprise dev teams across finance, energy, agriculture, and pure software companies, watching AI adoption succeed and fail. From why adoption is a change management problem rather than a technology one, to the anatomy of an AI harness and the external layer successful companies build on top of it, to the context engineer and agent ops roles now appearing in team topologies, to managing agent skills as versioned dependencies instead of letting them pollute the repo, this is the enterprise AI adoption conversation from someone who sees a hundred versions of it.

📖 Read Takeaways
Back to Blog