Skip to content
ExponaBLOG
REACT & NEXT.JS · DAY 006 / 100

You might not need useEffect: what developers should know

Most effects in code reviews should not exist.

Expona Admin9 min read

You might not need useEffect: what developers should know

React developers spend enormous amounts of time debugging effects. We've all stared at infinite loops, race conditions, and stale closures. The frustrating truth: most of the effects we write shouldn't exist. Understanding when useEffect belongs in your code—and when it doesn't—is one of the fastest ways to improve your React skills and ship fewer bugs.

This article walks through the five patterns that eliminate 80% of the effects in production codebases. We're not saying effects are bad. We're saying you're probably using them wrong, and simpler solutions exist.

Effects are for syncing with outside systems

useEffect has one job: keep your component synchronized with something outside React. That means subscriptions, timers, DOM APIs, WebSocket listeners, or non-React libraries. If you're not touching something outside React's world, you don't need an effect.

A real-world example: you're building a chat component. Messages come from a WebSocket server. The component mounts, you subscribe. The component unmounts, you unsubscribe. That's a textbook effect.

tsx
function ChatRoom({ roomId }) {
  const [messages, setMessages] = React.useState([]);

  React.useEffect(() => {
    const socket = connectWebSocket(roomId);

    socket.on('message', (msg) => {
      setMessages((prev) => [...prev, msg]);
    });

    return () => {
      socket.disconnect();
    };
  }, [roomId]);

  return (
    <div>
      {messages.map((msg) => (
        <p key={msg.id}>{msg.text}</p>
      ))}
    </div>
  );
}

This effect is justified. It has a cleanup function. It runs only when roomId changes. It's managing an external resource that React can't control.

But we see developers reach for effects when they should reach for simpler patterns. Scroll to the bottom of a list when it updates? That's a DOM mutation and can belong in an effect—but so can a ref-based approach. Poll an API every 30 seconds? That's an interval timer, so an effect works. But if you're fetching data on mount or when props change, that's a different story entirely.

Transform data during render

Filtering, sorting, mapping, and formatting all belong in the render body. Not in effects. Not in state. Render those transformations directly.

This pattern confuses junior developers because it seems like effects should be doing computation. They're not. The rendering phase is the right place.

Here's a common mistake:

tsx
// ❌ Wrong: computing in an effect
function UserList({ users, searchTerm }) {
  const [filtered, setFiltered] = React.useState([]);

  React.useEffect(() => {
    setFiltered(
      users.filter((u) => u.name.toLowerCase().includes(searchTerm.toLowerCase()))
    );
  }, [users, searchTerm]);

  return (
    <ul>
      {filtered.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Why is this wrong? The filtering happens twice: once during render (when React evaluates the effect), and again when the effect actually runs. You've also introduced an extra state variable. If searchTerm changes, the component renders with stale data before the effect updates it.

The fix is immediate:

tsx
// ✅ Right: computing during render
function UserList({ users, searchTerm }) {
  const filtered = users.filter((u) =>
    u.name.toLowerCase().includes(searchTerm.toLowerCase())
  );

  return (
    <ul>
      {filtered.map((u) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Now the data is always fresh. The computation happens synchronously. No extra state. No effect. This applies to sorting, grouping, deriving values from props—anything that's a pure function of your current props and state.

If the transformation is expensive, you can memoize it with useMemo. But even then, you're not using an effect. You're optimizing a render-time computation.

tsx
// ✅ Also right: expensive computation with useMemo
const filtered = React.useMemo(
  () => users.filter(/* ... */),
  [users, searchTerm]
);

Handle events in event handlers

Form submissions, button clicks, toggling dialogs—these are events. They should be handled in event handlers, not effects.

This pattern emerges when developers think they need to "react to" a prop change. A common scenario: a user clicks a button that sets a flag to true, and you want to submit the form when that flag changes. Don't watch the flag with an effect. Put the submission logic in the event handler.

Here's the wrong approach:

tsx
// ❌ Wrong: watching a flag in an effect
function Form() {
  const [formData, setFormData] = React.useState({ name: '', email: '' });
  const [shouldSubmit, setShouldSubmit] = React.useState(false);

  React.useEffect(() => {
    if (shouldSubmit) {
      submitForm(formData);
      setShouldSubmit(false);
    }
  }, [shouldSubmit, formData]);

  return (
    <>
      <input
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <button onClick={() => setShouldSubmit(true)}>Submit</button>
    </>
  );
}

This creates a chain of state updates and re-renders. If the user rapidly clicks the button, the effect runs multiple times. You've made your component harder to test and reason about.

The right way:

tsx
// ✅ Right: handling events in event handlers
function Form() {
  const [formData, setFormData] = React.useState({ name: '', email: '' });
  const [loading, setLoading] = React.useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setLoading(true);
    try {
      await submitForm(formData);
    } finally {
      setLoading(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
      />
      <button type="submit" disabled={loading}>
        {loading ? 'Submitting...' : 'Submit'}
      </button>
    </form>
  );
}

The logic is clear, direct, and synchronous with user intent. The component is easier to debug because you're not chasing effect side effects.

Fetch with a library or the server

Data fetching is where effects get the most abused. Writing your own fetch effects leads to race conditions, missing loading states, cache invalidation problems, and stale-while-revalidate logic you forgot to implement.

If you're writing fetch in a useEffect, you should ask: why am I not using TanStack Query? Or Server Components? Or an API route?

Here's the manual effect approach, which has subtle bugs:

tsx
// ❌ Risky: manual fetch in an effect
function UserProfile({ userId }) {
  const [user, setUser] = React.useState(null);
  const [error, setError] = React.useState(null);

  React.useEffect(() => {
    let cancelled = false;

    const fetchUser = async () => {
      try {
        const res = await fetch(`/api/users/${userId}`);
        const data = await res.json();
        if (!cancelled) setUser(data);
      } catch (e) {
        if (!cancelled) setError(e);
      }
    };

    fetchUser();
    return () => {
      cancelled = true;
    };
  }, [userId]);

  if (error) return <p>Error: {error.message}</p>;
  if (!user) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

You've written 20 lines to handle a single fetch. You're manually managing cancellation, error states, and loading states. You're not caching results. If the user navigates back to this component, you refetch. You're not handling stale-while-revalidate. You're not deduplicating concurrent requests.

With TanStack Query:

tsx
// ✅ Right: using TanStack Query
function UserProfile({ userId }) {
  const { data: user, error, isPending } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then((r) => r.json()),
  });

  if (error) return <p>Error: {error.message}</p>;
  if (isPending) return <p>Loading...</p>;
  return <p>{user.name}</p>;
}

Or with Server Components:

tsx
// ✅ Also right: using Server Components (async component)
async function UserProfile({ userId }) {
  const user = await fetch(`/api/users/${userId}`).then((r) => r.json());

  return <p>{user.name}</p>;
}

Both eliminate effects entirely. TanStack Query gives you client-side caching and synchronization. Server Components push data fetching off the client. Either way, you're not writing effect code for every data need.

Reset state with a key

Sometimes you need to reset a component's state. New props come in, and you want a fresh slate. Developers often reach for an effect that watches props and calls reset functions. There's a simpler way: change the key.

When you change a component's key, React remounts it. All state goes away. No effect needed.

Example: a modal component that edits different items. Each time the item changes, you want a fresh form state.

tsx
// ❌ Wrong: resetting in an effect
function EditItemModal({ itemId }) {
  const [title, setTitle] = React.useState('');
  const [description, setDescription] = React.useState('');

  React.useEffect(() => {
    // Reset form when itemId changes
    setTitle('');
    setDescription('');
  }, [itemId]);

  return (
    <form>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
      />
      <textarea
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        placeholder="Description"
      />
    </form>
  );
}

You're calling setters to reset state. If you have 10 fields, that's 10 lines. If you add a new field, you have to remember to reset it.

The fix:

tsx
// ✅ Right: using key to remount
function EditItemModal({ itemId }) {
  const [title, setTitle] = React.useState('');
  const [description, setDescription] = React.useState('');

  return (
    <form key={itemId}>
      <input
        value={title}
        onChange={(e) => setTitle(e.target.value)}
        placeholder="Title"
      />
      <textarea
        value={description}
        onChange={(e) => setDescription(e.target.value)}
        placeholder="Description"
      />
    </form>
  );
}

When itemId changes, React sees a new key and remounts the form. All state resets automatically. No effect. No manual resets. The component is cleaner and easier to maintain.

Common mistakes

Mistake 1: Effects that run on every render. If you write an effect without a dependency array, it runs after every render. Usually this is accidental. Always include a dependency array.

Mistake 2: Using effects for computed values. Derived data belongs in the render body or useMemo. Not in state updated by an effect. You'll get stale data between renders.

Mistake 3: Fetching in effects without deduplication. Two components mounting at the same time will send two identical requests. Use a query library or fetch on the server.

Mistake 4: Forgetting cleanup functions. Timers, subscriptions, and listeners need to be removed. Always return a cleanup function from effects that create external resources.

Mistake 5: Unnecessary dependencies. Including too many dependencies causes effects to run too often. Include only values that truly determine when the effect should rerun.

Key takeaways

  • Effects sync with external systems: WebSockets, timers, DOM APIs, non-React libraries. Don't use them for everything.
  • Transform data during render, not in effects. Filter, sort, and map inside your component body or with useMemo.
  • Handle events in event handlers. Don't watch flags or props with effects to trigger side effects.
  • Use TanStack Query or Server Components for data fetching. Stop writing fetch logic in effects.
  • Reset component state by changing the key. You don't need an effect that calls setState for every field.
  • If you're writing an effect, ask yourself: what external system am I syncing with? If the answer is unclear, you probably don't need it.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles