React useMount Hook: Run an Effect Once on Mount (2026)
"Run this once, when the component appears." It's the single most common effect in React - focus an input, fire an analytics event, open a connection, read from a browser API. The idiom everyone reaches for is useEffect with an empty dependency array: useEffect(() => { trackPageView("/checkout"); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); It works, but look at what it costs: an empty array you must remember (forget it and the effect runs on every render), a lint suppression comment whenever the effect touches anything, and - worst of all - zero stated intent. useEffect(fn, []) says how; it never says why. Six months later, a teammate adds a dependency to that array "to fix the lint warning" and your run-once effect quietly becomes a run-on-change effect. useMount from @reactuses/core is the named version of this idiom: useMount(fn) runs fn exactly once per mount, and the name is the documentation. This post covers what it actually compiles down to, the React 18+ StrictMode double-run that surprises everyone the first time, the subtle stale-closure bug in hand-rolled unmount cleanups (and how useUnmount dodges it), async work on mount, and - just as important - the cases where you should not use it. Quick Start npm install @reactuses/core import { useMount, useUnmount } from "@reactuses/core"; import { useRef } from "react"; function SearchBox() { const inputRef = useRef (null); useMount(() => { inputRef.current?.focus(); }); useUnmount(() => { console.log("search box removed"); }); return ; } No dependency array, no lint comment, and the reader knows the intent before reading the body: this runs on mount, full stop. What useMount Actually Is No magic - here is the entire implementation, minus a dev-only type check: export const useMount = (fn: () => void) => { useEffect(() => { fn?.(); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); }; That's it: useEffect with an empty array, wrapped once so you never write the array or the suppression comment again. Three things fall out of this five-line definition: - Timing is useEffect timing. The callback fires after the component is committed to the DOM - after first paint, browser APIs available. It is notuseLayoutEffect ; if you need to measure and mutate before paint, reach for a layout effect instead. - It is SSR-safe by construction. Effects simply never run on the server, so useMount is the natural home forwindow /document access in SSR apps - the same guarantee the manualuseEffect(fn, []) gives you, with the intent spelled out. - The return value is ignored. useMount callsfn?.() and discards the result - it does not forward a returned function to React as cleanup. Cleanup belongs touseUnmount (below). A side effect of this design: passing anasync function is safe, which is more than you can say for rawuseEffect (more on that in a minute). One consequence to internalize: because the dependency array is empty, the callback closes over first-render values. Props and state read inside useMount are frozen at their initial values. For a mount effect that's almost always what you want - but if you find yourself wanting fresh values inside, that's the signal you actually want useEffect with dependencies, or a useLatest ref. The StrictMode Gotcha: "Why Does My Mount Effect Run Twice?" Search "useEffect runs twice" and you'll find a decade of confusion. Here's the short version: since React 18, in development deliberately mounts every component, unmounts it, and mounts it again. Any mount effect - useEffect(fn, []) , useMount , doesn't matter - runs twice in dev. In production it runs once. React does this on purpose, to surface effects that don't clean up after themselves. The official guidance is: don't fight the double-run, make the effect idempotent - running it twice should be harmless because the cleanup undoes the first run: useMount(() => { const controller = new AbortController(); fetch("/api/config", { signal: controller.signal }).then(applyConfig); // pair with useUnmount(() => controller.abort()) }); But some effects are genuinely once-only, and running them twice is a real bug, not a hygiene warning: an analytics beacon fires twice, a welcome toast pops twice, a payment-intent gets created twice in dev and QA files a ticket. For those, @reactuses/core ships useOnceEffect : import { useOnceEffect } from "@reactuses/core"; useOnceEffect(() => { trackPageView("/checkout"); // fires once, even under StrictMode }); The trick inside is elegant: useOnceEffect records each effect function in a WeakSet before running it. StrictMode's remount re-invokes the same effect function instance from the same render, so the second invocation finds it already recorded and bails. Genuine remounts (the component actually left and came back) create a fresh function and run again - exactly the semantics "once per mount, ignoring StrictMode's rehearsal" implies. Rule of thumb: useMount + idempotent by default; useOnceEffect when a double-fire is observable to a user or a backend. useUnmount - and the Stale-Closure Trap It Avoids The obvious hand-rolled unmount cleanup has a bug most people ship without noticing: // β οΈ hand-rolled version useEffect(() => { return () => { saveDraft(draft); // draft from the FIRST render - always empty! }; }, []); // empty deps β the cleanup closure was created on render #1 The cleanup function was created on the first render, so it captured the first render's draft . When the component unmounts three minutes and forty keystrokes later, it saves an empty string. The "fix" of adding draft to the deps is worse - now the cleanup runs on every keystroke, not on unmount. useUnmount solves this properly. Internally it stores your callback in a useLatest ref that's updated every render, and the unmount cleanup calls through the ref: import { useUnmount } from "@reactuses/core"; function Composer() { const [draft, setDraft] = useState(""); useUnmount(() => { saveDraft(draft); // β the draft as of the LAST render }); return setDraft(e.target.value)} />; } Your callback runs exactly once, at unmount, and sees the latest state. This is the concrete reason to prefer useUnmount over the return () => {} idiom whenever the cleanup reads state or props - it's not sugar, it's a bug fix. Async Work on Mount Raw useEffect famously rejects async functions - useEffect(async () => {...}, []) hands React a Promise where it expects a cleanup function, and you get a warning plus a skipped cleanup. Because useMount discards the callback's return value, an async callback is perfectly fine: useMount(async () => { const user = await fetchCurrentUser(); setUser(user); }); One thing this doesn't give you is protection against the component unmounting mid-await - calling setUser after unmount is harmless in React 18+ but often still not what you want (you may be writing to state that a remounted instance will clobber). Two library answers: - useMountedState returns anisMounted() function backed by a ref - check it after eachawait : const isMounted = useMountedState(); useMount(async () => { const user = await fetchCurrentUser(); if (isMounted()) setUser(user); }); - useAsyncEffect generalizes the pattern for effects with dependencies, handing your async body a liveness check and supporting cleanup. For real data fetching with caching and retries you'll outgrow both - that's React Query / SWR territory, or your framework's loaders. useMount is for the one-shot imperative stuff around the edges. The Mirror Image: Skipping the Mount Sometimes you want the opposite - react to changes but not to the initial mount. Sync a filter to the URL, but don't rewrite the URL on first load; show "settings saved" on change, but not on arrival. That's useUpdateEffect , and its primitive sibling useFirstMountState which simply tells you whether this is the first render: import { useUpdateEffect } from "@reactuses/core"; useUpdateEffect(() => { syncFilterToUrl(filter); // runs on filter changes, skips the mount }, [filter]); Together the four hooks cover the whole lifecycle vocabulary that classes used to spell componentDidMount / componentDidUpdate / componentWillUnmount : | You want to run codeβ¦ | Reach for | |---|---| | once, after the component appears | useMount | | once, even under StrictMode's dev double-run | useOnceEffect | | when the component is removed, seeing latest state | useUnmount | | on updates only, skipping the first render | useUpdateEffect | | conditionally, based on "is this the first render?" | useFirstMountState | When Not to Use useMount Honesty section. useMount is intent-naming sugar over a real React primitive, and the primitive is sometimes the right call: - The effect reads a prop or state that can change. If roomId changes and you need to reconnect, that isuseEffect(connect, [roomId]) - a mount hook here is a synchronization bug wearing a convenience API. The empty array isn't ceremony in that case; it's wrong. - You're fetching server data for rendering. Framework loaders, React Query, SWR - anything with caching, deduplication, and revalidation beats a fetch in a mount effect. React's own docs have walked away from "fetch in useEffect" as a primary pattern. - You need pre-paint measurement. useMount is post-paint. Measure-then-mutate work belongs in a layout effect. - The "mount event" is actually a user event. If code can run in the click handler that caused the component to appear, run it there - effects are for synchronizing with external systems, not a junk drawer. The test is one question: would this code ever need to re-run while the component is alive? If the answer is any form of "yes, when X changes," you want useEffect and a dependency array. If it's a clean "no," useMount says so in a way [] never will. Takeaways - useMount(fn) isuseEffect(fn, []) with the intent in the name - no array to forget, no lint suppression, first-render closure semantics you should embrace, not fight. - In React 18+ dev StrictMode every mount effe
Comments
No comments yet. Start the discussion.