Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save shawngustaw/acb0ca5401a8fbb4a919f8a57bfe2843 to your computer and use it in GitHub Desktop.

Select an option

Save shawngustaw/acb0ca5401a8fbb4a919f8a57bfe2843 to your computer and use it in GitHub Desktop.
DO_NOT_USE_EFFECT_OR_YOU_WILL_BE_FIRED_SKILL.md
name you-might-not-need-an-effect
description Consult this skill whenever writing, editing, or reviewing React code that involves (or might involve) useEffect. Trigger on any React component work where you're tempted to reach for useEffect — including syncing state to other state, transforming data for rendering, responding to events, resetting state on prop changes, chaining state updates, initializing the app, notifying parent components, data fetching, or any "I want X to happen when Y changes" instinct. Also trigger when reviewing existing code that already uses useEffect. Treat useEffect as effectively banned — almost every use is a code smell with a simpler, more correct alternative, and every "just in case" Effect is the seed of the next race condition or infinite loop. Based on the React docs "You Might Not Need an Effect" and Alvin Sng's "Why we banned React's useEffect".

You Might Not Need an Effect

Treat useEffect as effectively banned. Not literally forbidden — there are a handful of legitimate uses covered at the bottom — but the prior should be "I will not write this" until proven otherwise. Every Effect you write is an opportunity for a race condition, infinite loop, or cascading re-render. Every Effect you don't write is a simpler, more predictable component.

This prior is especially important when agents write code. Agents (including you) have a deep-seated instinct to reach for useEffect whenever the shape of the problem is "X should happen when Y changes." That instinct is almost always wrong. useEffect has become shorthand for "I don't know where this logic goes, so I'll let a dependency array figure it out" — and that's exactly the class of bug this skill exists to prevent.

This project uses the React Compiler, which means manual useMemo and useCallback are almost never needed either. Compute values inline; the compiler handles memoization.

The rule

Never write useEffect without first walking the checklist below. If any answer is "yes," use the linked replacement. Only if every answer is "no" and the remaining case fits one of the legitimate uses should you write the hook.

For the rare legitimate case, prefer useMountEffect (see the escape hatch) to make "sync on mount" intent explicit.

The decision checklist

Walk these in order before writing any useEffect:

  1. Can this value be calculated from existing props/state during render? → Compute it inline. See Derive, don't sync.
  2. Am I about to memoize an expensive calculation? → With React Compiler, just compute inline. See Expensive calculations.
  3. Is this logic a response to a specific user interaction (click, submit, drag)? → Event handler. See Event handlers, not effects.
  4. Am I using state as a flag to trigger an action in an effect? → Call the action directly in the handler. See No action relays.
  5. Am I resetting state when a prop changes? → Use key. See Reset with key, not choreography.
  6. Am I chaining effects that each setState to trigger the next? → Collapse into the event handler. See Chains of computations.
  7. Am I running an effect with a guard like if (!isReady) return at the top? → Conditionally mount the component instead. See Conditional mounting.
  8. Am I notifying a parent of a state change? → Lift state up or call the callback in the handler. See Notifying parents.
  9. Am I subscribing to an external store (browser API, mutable third-party data)? → Use useSyncExternalStore. See External stores.
  10. Is this data fetching? → Use the project's query library / framework loader. See Data fetching.
  11. Is this app initialization (run once at startup)? → Module-level code or didInit flag. See App initialization.

The core principle

Use Effects only for code that should run because the component was displayed to the user and needs to sync with an external system.

  • Code that can be derived from current state/props → compute during render.
  • Code that should run because the user did something → event handler.
  • Code that should run because the component appeared on screen and needs external-system sync → useMountEffect or useEffect.

The escape hatch: useMountEffect

For genuine mount-time synchronization with an external system, wrap useEffect in a named hook that makes intent explicit. This is the pattern from Factory's codebase:

// One place in the codebase, lint-exempted:
export function useMountEffect(effect: () => void | (() => void)) {
  useEffect(effect, []);
}

Then in components:

// ✅ Clear intent: run once on mount, cleanup on unmount
useMountEffect(() => {
  connectionManager.on('connected', handleConnect);
  return () => connectionManager.off('connected', handleConnect);
});

useMountEffect is not a fig leaf for writing Effects with dependencies — it's only for the [] case where the dependency would be stable anyway (singletons from context, refs that don't change). If you have real reactive dependencies, you're probably doing something the checklist told you not to.

Anti-patterns and their fixes

1. Derive, don't sync

Smell: useState + useEffect where the effect just calls setX(deriveFromY(y)).

// 🔴 BAD — two render cycles, first stale, then corrected
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
}

// 🔴 BAD — same pattern with filtering
function ProductList({ products }) {
  const [filteredProducts, setFilteredProducts] = useState([]);
  useEffect(() => {
    setFilteredProducts(products.filter(p => p.inStock));
  }, [products]);
}
// ✅ GOOD — one render, no sync
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  const fullName = firstName + ' ' + lastName;
}

function ProductList({ products }) {
  const filteredProducts = products.filter(p => p.inStock);
}

Rule: If a value can be calculated from existing state or props, it is not state. Compute it during render.

2. Expensive calculations (with React Compiler)

Smell: You're tempted to useEffect + setState to "cache" a computation.

// 🔴 BAD
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
  setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ✅ GOOD — React Compiler handles memoization automatically
const visibleTodos = getFilteredTodos(todos, filter);

Rule: With React Compiler, compute inline. The compiler memoizes based on actual data-flow analysis, usually better than hand-written useMemo. Only reach for manual useMemo if you've measured and confirmed the compiler didn't catch a specific hot path — and even then, never useEffect.

3. Event handlers, not effects

Smell: Logic that should run because the user did X is placed in an effect that watches some state.

// 🔴 BAD — notification fires on every page load if cart is persisted
useEffect(() => {
  if (product.isInCart) {
    showNotification(`Added ${product.name} to cart!`);
  }
}, [product]);
// ✅ GOOD — shared function called from the event handlers that caused the action
function buyProduct() {
  addToCart(product);
  showNotification(`Added ${product.name} to cart!`);
}
function handleBuyClick() { buyProduct(); }
function handleCheckoutClick() { buyProduct(); navigateTo('/checkout'); }

Rule: By the time an effect runs, it has lost the context of what the user did. Put the logic where that context still exists — the event handler.

Nuance: POST /api/register belongs in a submit handler. POST /analytics/event for "page viewed" belongs in a mount effect (it fires because the component displayed, not because of an interaction).

4. No action relays

Smell: You use state as a flag, set it in a handler, then watch it in an effect to do the real work.

// 🔴 BAD — state is a relay, not meaningful state
function LikeButton() {
  const [liked, setLiked] = useState(false);
  useEffect(() => {
    if (liked) {
      postLike();
      setLiked(false); // reset the flag
    }
  }, [liked]);
  return <button onClick={() => setLiked(true)}>Like</button>;
}
// ✅ GOOD — just do the thing
function LikeButton() {
  return <button onClick={() => postLike()}>Like</button>;
}

Smell test (worth internalizing): "set flag → effect runs → reset flag" is always wrong. The flag is a symptom of logic that should have been in the handler.

5. Reset with key, not choreography

Smell: An effect whose only job is to reset local state when a prop changes.

// 🔴 BAD — stale render, then reset render; also doesn't propagate to nested state
function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');
  useEffect(() => {
    setComment('');
  }, [userId]);
}
// ✅ GOOD — outer component passes key, inner component resets for free
export default function ProfilePage({ userId }) {
  return <Profile userId={userId} key={userId} />;
}

function Profile({ userId }) {
  const [comment, setComment] = useState(''); // resets automatically on key change
}

Same pattern for "load resource when ID changes":

// 🔴 BAD — useEffect emulates remount behavior
function VideoPlayer({ videoId }) {
  useEffect(() => { loadVideo(videoId); }, [videoId]);
}

// ✅ GOOD — key forces clean remount, useMountEffect runs once per instance
function VideoPlayerWrapper({ videoId }) {
  return <VideoPlayer key={videoId} videoId={videoId} />;
}
function VideoPlayer({ videoId }) {
  useMountEffect(() => { loadVideo(videoId); });
}

Rule: To reset a subtree when a value changes, pass that value as a key. React will unmount and remount, resetting all state within.

Adjusting some state on prop change

If you only need to reset a piece of state (not the whole tree), first try to restructure so nothing needs resetting:

// ✅ BEST — store the ID, derive the selection during render
const [selectedId, setSelectedId] = useState(null);
const selection = items.find(item => item.id === selectedId) ?? null;

Second-best: adjust during render with a guard (not in an effect):

// ✅ OK — rare, but valid
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
  setPrevItems(items);
  setSelection(null);
}

6. Chains of computations

Smell: A sequence of effects where each watches a state and sets the next.

// 🔴 BAD — re-renders between every link; brittle when any upstream state changes
useEffect(() => { if (card?.gold) setGoldCardCount(c => c + 1); }, [card]);
useEffect(() => { if (goldCardCount > 3) { setRound(r => r + 1); setGoldCardCount(0); } }, [goldCardCount]);
useEffect(() => { if (round > 5) setIsGameOver(true); }, [round]);
useEffect(() => { if (isGameOver) alert('Good game!'); }, [isGameOver]);
// ✅ GOOD — derive what you can, do the rest in the handler
const isGameOver = round > 5;

function handlePlaceCard(nextCard) {
  if (isGameOver) throw Error('Game already ended.');
  setCard(nextCard);
  if (nextCard.gold) {
    if (goldCardCount < 3) {
      setGoldCardCount(goldCardCount + 1);
    } else {
      setGoldCardCount(0);
      setRound(round + 1);
      if (round === 5) alert('Good game!');
    }
  }
}

Rule: Effects that exist to trigger other effects are always wrong. Collapse the chain into the event handler that started it.

7. Conditional mounting

Smell: An effect that starts with a guard like if (!ready) return or if (isLoading) return.

// 🔴 BAD — guard inside effect, still runs (and re-runs) when isLoading flips
function VideoPlayer({ isLoading }) {
  useEffect(() => {
    if (!isLoading) playVideo();
  }, [isLoading]);
}
// ✅ GOOD — don't mount the component until preconditions are met
function VideoPlayerWrapper({ isLoading }) {
  if (isLoading) return <LoadingScreen />;
  return <VideoPlayer />;
}
function VideoPlayer() {
  useMountEffect(() => playVideo());
}

Rule: If an effect only does useful work when some condition holds, express that condition through conditional rendering at the parent level. The child's mount is the event.

8. Notifying parents

Smell: Child has internal state; effect calls onChange(state) after the update.

// 🔴 BAD — extra render pass; onChange fires after React has already painted
useEffect(() => {
  onChange(isOn);
}, [isOn, onChange]);
// ✅ GOOD — update both in the same event
function updateToggle(nextIsOn) {
  setIsOn(nextIsOn);
  onChange(nextIsOn);
}
// ✅ EVEN BETTER — lift state up, fully controlled component
function Toggle({ isOn, onChange }) {
  function handleClick() { onChange(!isOn); }
  // no internal state at all
}

Rule: When two components need the same data, don't synchronize — lift the state up so there's a single source of truth. Relatedly, don't pass data up via an effect: fetch in the parent and pass down.

9. External stores

Smell: Manual addEventListener + setState inside an effect.

// 🔴 NOT IDEAL
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
  function updateState() { setIsOnline(navigator.onLine); }
  updateState();
  window.addEventListener('online', updateState);
  window.addEventListener('offline', updateState);
  return () => {
    window.removeEventListener('online', updateState);
    window.removeEventListener('offline', updateState);
  };
}, []);
// ✅ GOOD — useSyncExternalStore is purpose-built for this
function subscribe(callback) {
  window.addEventListener('online', callback);
  window.addEventListener('offline', callback);
  return () => {
    window.removeEventListener('online', callback);
    window.removeEventListener('offline', callback);
  };
}

function useOnlineStatus() {
  return useSyncExternalStore(
    subscribe,
    () => navigator.onLine,  // client snapshot
    () => true               // server snapshot
  );
}

Rule: Use useSyncExternalStore. Safer with concurrent rendering, less boilerplate, and it's the hook React designed for this exact problem.

10. Data fetching

Smell: useEffect that calls fetch(...) and then setState(...).

// 🔴 BAD — race condition risk, no caching, no retries, no loading state
function ProductPage({ productId }) {
  const [product, setProduct] = useState(null);
  useEffect(() => {
    fetchProduct(productId).then(setProduct);
  }, [productId]);
}
// ✅ GOOD — query library handles cancellation, caching, staleness, retries
function ProductPage({ productId }) {
  const { data: product } = useQuery(['product', productId], () =>
    fetchProduct(productId)
  );
}

Preferred order:

  1. Framework data loader (Next.js route loaders / Server Components, Remix loaders) — handles SSR and waterfalls.
  2. Query library — TanStack Query, SWR, Apollo, Relay. This is the default in most codebases; use whatever the project already has.
  3. Custom useQuery / useData hook — wraps the effect + cleanup once, so components don't reinvent it.
  4. Raw useEffect with cleanup — only as a true last resort. Cleanup is mandatory:
useEffect(() => {
  let ignore = false;
  fetchResults(query, page).then(json => {
    if (!ignore) setResults(json);
  });
  return () => { ignore = true; };
}, [query, page]);

11. App initialization

Smell: useEffect(() => { ... }, []) at the top of App for one-time-on-startup logic.

// 🔴 BAD — runs twice in StrictMode dev
useEffect(() => {
  loadDataFromLocalStorage();
  checkAuthToken();
}, []);
// ✅ GOOD — module-level, runs once per app load
if (typeof window !== 'undefined') {
  checkAuthToken();
  loadDataFromLocalStorage();
}

// ✅ ALSO GOOD — module-level guard if it has to be inside a component
let didInit = false;
function App() {
  useMountEffect(() => {
    if (!didInit) {
      didInit = true;
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  });
}

Rule: Components should tolerate being remounted. Once-per-app-load logic goes at module scope or behind a module-level flag.

Component structure convention

When you're writing a fresh component, follow this order. It naturally steers away from Effects by putting "computed values" in their correct position — between state and handlers, not in an effect.

export function FeatureComponent({ featureId }: Props) {
  // 1. External data hooks (queries, context)
  const { data, isLoading } = useQueryFeature(featureId);
  const user = useUser();

  // 2. Local state
  const [isOpen, setIsOpen] = useState(false);

  // 3. Computed values (NEVER useEffect + setState)
  const displayName = user?.name ?? 'Unknown';
  const canEdit = data?.ownerId === user?.id;

  // 4. Event handlers
  const handleClick = () => setIsOpen(true);

  // 5. Mount-time external sync (if any — usually none)
  // useMountEffect(() => { ... });

  // 6. Early returns
  if (isLoading) return <Loading />;

  // 7. Render
  return <div>...</div>;
}

If you're tempted to add a useEffect anywhere in this skeleton, stop and walk the checklist.

Legitimate uses of useEffect

Not every Effect is wrong. The remaining valid cases — all of which are external-system synchronization — include:

  • Attaching a non-React widget to a DOM node (map libraries, charting libraries, imperative canvas work).
  • Connecting to a WebSocket, SSE stream, or chat room.
  • Subscribing to an event emitter from a non-React library.
  • Sending a "component displayed" analytics event.
  • Imperative DOM control that has no declarative React API (rare — many cases have better solutions).

For all of these, prefer useMountEffect when the dependency array would be []. It makes the intent legible and signals to reviewers (and future agents) that this is the deliberate, blessed escape-hatch form.

How to respond when asked for code that "looks like" it needs an Effect

  1. Don't write the Effect first. Walk the checklist.
  2. If an alternative applies, write that version and briefly explain why: "I'd normally reach for useEffect here, but we can derive this during render, which avoids an extra re-render and a whole class of sync bugs."
  3. If useEffect (or useMountEffect) really is right, say so explicitly and name which legitimate-use category it falls into. This reassures the reader you didn't pick it reflexively.
  4. Always include cleanup for subscriptions and async work. An Effect without cleanup where one is needed is a bug, not a shortcut.

Quick reference — symptom to fix

Symptom Fix
useState + useEffect that mirrors other state Plain const during render
useEffect to cache a computation Inline computation (React Compiler memoizes it)
useEffect that resets state on prop change key on a child component
useEffect that sets state based on other state Derive during render
useEffect triggered by "did the user do X" Event handler
useEffect with a flag that's set then reset Call the action directly in the handler
Multiple useEffects chained together One event handler doing all updates
useEffect with if (!ready) return guard Conditionally mount the component
useEffect(..., []) for startup logic Module-level init or didInit flag
useEffect that calls props.onChange(state) Call onChange in the handler, or lift state up
useEffect + addEventListener on browser API useSyncExternalStore
useEffect that fetches data Framework loader or useQuery
Genuine mount-time external sync, [] deps useMountEffect

Recap

  • Treat useEffect as effectively banned. The prior is "don't write it" until proven otherwise.
  • If you can calculate something during render, you don't need an Effect.
  • With React Compiler, skip manual useMemo too — compute inline.
  • To reset a subtree, pass a different key.
  • Code that runs because the user did something belongs in handlers.
  • Code that runs because a component appeared and needs external sync belongs in useMountEffect (or useEffect).
  • State flags that exist to trigger effects are always the wrong shape — do the thing directly.
  • Effects that exist to trigger other effects are always wrong — collapse into one handler.
  • Fetching goes through a query library or framework loader, not raw effects.
  • When useEffect really is right, prefer useMountEffect so intent is explicit.

When in doubt: delete the Effect and see if the code still needs to exist.


Sources

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment