---
title: "10 React tips I wish someone had told me before I mass-produced bugs"
publishDate: 2026-03-25T00:00:00.000Z
excerpt: "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."
category: "react"
tags: ["react", "javascript", "performance", "hooks", "frontend"]
canonical: https://neciudan.dev/10-react-tips-that-actually-matter
---

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 `useEffect`s 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.

```javascript
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.

```javascript
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 `useState`s 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:

```javascript
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.

```javascript
// 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.

```javascript
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.

```javascript
// 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](https://react.dev/learn/you-might-not-need-an-effect). 

## 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.)

```javascript
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.

```javascript
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.

```javascript
// 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.

```javascript
// 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.

```javascript
// 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.

```javascript
// 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

```javascript
<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.

```javascript
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:

```javascript
<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](/programs/daily-react), 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](https://github.com/Cst2989/react-tips-skill) you can install in two commands:

```bash
/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! 
