The Good Tech Companies - React Activity: When A Render No Longer Guarantees an Effect

Episode Date: September 11, 2026

This story was originally published on HackerNoon at: https://hackernoon.com/react-activity-when-a-render-no-longer-guarantees-an-effect. Discover how React 19.2 Activit...y changes component lifecycles, Effects, state, and cleanup—and how to avoid hidden bugs in production. Check more stories related to undefined at: https://hackernoon.com/c/undefined. You can also check exclusive content about #react, #react-19, #react-hook, #react-development, #frontend, #react-components, #useeffect, #good-company, and more. This story was written by: @socialdiscoverygroup. Learn more about this writer by checking @socialdiscoverygroup's about page, and for more stories, please visit hackernoon.com. React 19.2’s lets you hide UI while preserving its state and DOM—but it also changes the lifecycle assumptions your code can safely make. Rendering doesn’t guarantee an Effect. Hidden Activity subtrees can render without their Effects mounting. Never create side effects during render. Subscriptions and other resources should be created and cleaned up within the same Effect, or use useSyncExternalStore for external stores. StrictMode is your early warning system. Its extra render and Effect cycles can expose impure rendering and asymmetric cleanup before they become production bugs. Think carefully about resource ownership. If a WebSocket, timer, or listener should survive hidden UI, manage it above the Activity boundary. The DOM stays mounted. Effects can be cleaned up while DOM nodes remain, so media and imperative widgets may need explicit cleanup. State preservation isn't always desirable. Activity works well for tabs and editors, but forms may unexpectedly retain old values and validation state. Hidden doesn't mean frozen. Activity subtrees can still render in the background and consume memory and resources. Activity can enable pre-rendering, but not every loading strategy preloads data. Effects won't run while hidden; Suspense-compatible loading can start during render. The key takeaway: Before using , don't just ask whether it preserves state—ask what code in the subtree relies on the old render → Effect → cleanup lifecycle sequence.

Transcript
Discussion (0)
Starting point is 00:00:00 This audio is presented by Hacker Noon, where anyone can learn anything about any technology. React activity. When a render no longer guarantees an effect by Social Discovery Group. React 19.2 introduced as a powerful way to hide UI while preserving its state and DOM. But beneath this seemingly simple feature is an important life cycle shift. A component can render without its effects ever mounting. That difference can expose hidden bugs in legacy code from subscriptions created during render to cleanup logic tied too closely to component visibility. In this article, we explore what React
Starting point is 00:00:35 Activity changes, why strict mode matters, and what to check before making part of your application. React 19. 2 introduced less than activity greater than an API that lets you hide UI while preserving its state and DOM. Turns into, we recently replaced a conditional render with activity in one of our projects. A little later we noticed that something was leaking. At first I suspected activity, because that the obvious recent change. It wasn't the cause. The actual problem was buried in an old hook that subscribed to an external store during render. The hook had effectively been relying on one assumption for years, if the component renders, an effect will eventually run and clean everything up. Activity was simply the first thing that made that assumption fail in production. How activity
Starting point is 00:01:22 actually works. The most useful mental model for me ended up looking like this. Visible state is preserved DOM as visible component renders as usual effects are mounted hidden state is preserved DOM stays mounted, but as hidden component can still render. E. G. Props changes effects are not mounted when activity transitions from visible to hidden. React hides its contents using display. None cleans up its effects, but preserves both the state and the DOM. Hidden children can still render when their props change, just at a lower priority. When activity becomes visible again, React restores the UI with its previous state and recreates the effects. If an activity starts out hidden, its effects simply don't mount. This was the part one initially got
Starting point is 00:02:06 wrong. I was still thinking about the component life cycle roughly like this. Render down pointing arrow effect down pointing arrow cleanup but with activity, that sequence is not something you can rely on. A hidden activity may render and simply stay hidden. Render down pointing arrow activity stays hidden down pointing arrow no effect that difference sounds small, but it is enough to expose code that creates resources during render and expects an effect to clean them up later. The first production bug, a side effect during render. Our project had an old hook that had been around for several years. Let's call it use Legacy Store. It had been written a long time ago, was used all over the code base, had test coverage, and nobody really looked inside it anymore. For new code,
Starting point is 00:02:50 it was simply an existing abstraction that people trusted. For the sake of the example, imagine it looked like this. From the outside, the new component looks completely harmless. The developer working on second tab may have no idea that somewhere inside the hook there is a manual subscription to an external store. Before activity, this code could appear to work perfectly well for years. The second tab would only mount when the user actually opened it. That resulted in a familiar sequence.
Starting point is 00:03:18 2nd tab mounts second tab unmounts the hook was already incorrect store subscribe as a side effect and side effects should not happen during render you cannot assume that every render will result in a commit followed by an effect but this particular bug could remain invisible for a long time then we wanted to preserve the state of the second tab so we replaced conditional rendering with activity once we switched to activity the hook's old life cycle assumption stopped holding react can render second tab ahead of time, which means use legacy store, can call store. Subscribe before the tab ever becomes visible. Under the activity contract described above, that render does not have to result in the effect being mounted. The subscription already exists, while the cleanup that is supposed to
Starting point is 00:04:04 call unsubscribe may never exist at all. Activity did not create an invalid life cycle. It simply made a previously ignored scenario possible. A resource is created during render even though ITS clean UP depends on a future effect. The worst part is that the problem is not in the code that introduced activity. The bug lives several abstraction layers deeper, inside a hook written years AGOAND used throughout the project. Greater than so what should the hook look like? The minimum fix is to make the subscription set up and clean up belong to the same effect. Now set up and clean up belong to the same effect. While the activity is hidden, there is no subscription. When the UI becomes visible, the effect creates one, and when it is hidden again, the effect cleans it up. If this is actually an
Starting point is 00:04:51 external store, there is usually no reason to implement the subscription manually with use effect in the first place. React provides use sync external store specifically for this use case. For example, if store, subscribe, returns an object with an unsubscribe method. Now the contract is explicit. React gets a subscription function, a store snapshot, and a cleanup function. That explicit It's ownership is exactly what the old hook was missing. Responsibility for the subscription had been split between render and an effect. Use sync external store removes the invalid assumption itself instead of merely fixing the specific way it surfaced with activity.
Starting point is 00:05:30 Why strict mode should have exposed this earlier? Looking at the bug afterwards, there was an uncomfortable realization. Strict mode had been trying to tell us about this for a long time. From the component's point of view, nothing looks suspicious. The bad part is hidden inside use. Legacy Store. In development, strict mode intentionally performs two useful checks. It invokes render functions an extra time to detect impure rendering, and it runs an additional set-up right-pointing arrow-set-up cycle for effects to uncover cleanup issues. These checks do not run in production.
Starting point is 00:06:04 For our hook, a simplified initial render can be thought of like this. Render number one right-pointing arrow use Legacy Store. Right-pointing arrow subscribe a right-pointing arrow render result discarded render number two right pointing arrow use legacy store right pointing arrow subscribe b right pointing arrow commit the problem is already obvious subscribe a exists even though the first render was discarded there is nobody responsible for unsubscribing it the extra effect check makes the asymmetry even more visible effect set up down pointing arrow strict mode clean up down pointing arrow unsubscribe be down pointing arrow effect set up again but the effect set up in our legacy hook does not call subscribed at all. The subscription is created during render. So the whole design is asymmetric.
Starting point is 00:06:50 Render creates the resource, while the effect only attempts to destroy it. A correct hook behaves very differently. Now the additional strict mode check is perfectly symmetrical. Effect set up right pointing arrow subscribe clean UP right pointing arrow unsubscribe effect set up right pointing arrow subscribe. That is why this case changed how I think about strict mode. If a legacy hook starts creating duplicate subscriptions, firing extra requests, or behaving strangely under the additional effect life cycle, that is not just some annoying development-only behavior. Strict mode is genuinely exposing code that depends too heavily on one particular render right-pointing arrow effect right-pointing arrow cleanup sequence. With activity, the exact same flaw can turn into a real
Starting point is 00:07:33 production bug. In fact, the activity documentation itself recommends strict mode as a way to catch these problems early. If strict mode is not enabled at the root of your application yet, enable it. There is one important nuance here. If strict mode is only enabled for a subtree, React does not run the additional initial effect cycle for that subtree. Doing so would create an effect ordering that could not happen in production without the corresponding parent life cycle. For the most complete coverage, it is better to enable strict mode at the root. Effects are no longer the same thing as a component life cycle. Fixing side effects during render, is only half of the story. We ran into another case where the effect itself was completely fine,
Starting point is 00:08:15 then the component gets wrapped in an activity, and suddenly the WebSocket disconnects every time the panel is hidden. That is expected behavior given the activity lifecycle, but it exposes a different problem. The life cycle of a background process has been coupled to the life cycle of a specific U.I component. If the WebSocket should exist only while the user can see notifications panel, then keeping the effect inside the activity is exactly right. But if the connection should remain active even while the panel is hidden, ownership of that connection needs to move above the activity boundary. The simplest option is to move the hook up.
Starting point is 00:08:50 Now use notifications connection lives in app, which means hiding notifications panel no longer tears down the WebSocket connection. If the data is needed in multiple parts of the application, the same ownership model can be expressed through a provider. The panel itself simply consumes the already available data from context, and the activity sits below the provider. Now the life cycle looks like this. Notifications provider right pointing arrow use notifications connection. Right pointing arrow web socket connected notifications panel visible right pointing arrow reads data from
Starting point is 00:09:22 context notification panel hidden right pointing arrow panel effects are cleaned up right pointing arrow web socket keeps running notifications panel visible again right pointing arrow receives the latest data from context in other words. Activity forces us to define a explicitly who actually owns the life cycle of an external resource. If the WebSocket belongs specifically to that panel, keeping the effect inside the panel is fine. If the connection should live independently of whether the panel is currently visible, its life cycle needs to be managed above the activity boundary. A useful question to ask is simple. Should this process exist only while the user can see this particular UI, the DOM stays around even after effect cleanup?
Starting point is 00:10:02 One detail surprised me more than it probably should have. Cleaning up the effects does not mean the DOM is gone. From the effects point of view, hiding an activity feels a bit like an unmount. From the DOM's point of view, it definitely isn't. React hides the subtree with display. None, but keeps the nodes around. That means this. Has very different behavior from this. With the second version, the less than video greater than node disappears. With activity, it doesn't, In the official React example, the video continues playing even after the activity becomes hidden, https colon slash slash react. Dev, reference, react, activity, UTM underscore source equals Chad GPT.
Starting point is 00:10:46 Comhash My Dash Hidden Dash Components Dash have dash unwanted dash side dash affects the same category of issue applies to less than audio greater than, less than iFrame greater than, and imperative third party widgets that rely on the DOM node being removed. The solution is to explicitly connect cleanup to the activity lifecycle. I am using use layout effect here because the cleanup is directly related to visually hiding the element. The React documentation notes that a regular use effect may be delayed in this kind of scenario, for example because of suspense or a view transition. The DOM node itself is still preserved. If the user returns to the tab, the less than video greater than can retain browser managed state such as the current playback position. So activity lets us preserve the DOM and its existence.
Starting point is 00:11:31 associated browser state, but we no longer get cleanup for free through DOM removal. State is preserved, which is sometimes good and sometimes not. State preservation is one of the main reasons to use activity. It can also be a problem. Suppose a Create Dialogue previously looked like this. The user opens the form, enters some data, closes it, and then opens it again. Because Create User form was unmounted, the new instance starts with clean state. Now we change the implementation.
Starting point is 00:12:00 The behavior changes. Close right-pointing arrow U.I. Hidden Open right-pointing arrow previous state restored the form may retain entered values, validation errors, a local draft, selected options, scroll position, and even uncontrolled DOM state. For tabs, this is often exactly what we want. For forms, it often is not. That is why activity should not be used mechanically as a replacement for every conditional render. If closing the U.I semantically means, this instance is finished. A normal unmount may be the correct behavior. If you still want to preserve the DOM or the rest of the subtree while resetting the state of a particular form instance, you can explicitly change its key. Each new opening changes the key. So React creates a fresh create user form with fresh state even though the activity itself remains alive. It is important to remember that with this approach, the entire form tree IS fully unmounted and then mounted again. A hidden subtree can still render. At one point I also caught myself thinking.
Starting point is 00:13:00 of a hidden activity as something close to a frozen page. It isn't. If data changes, very expensive screen can still render. React gives hidden work a lower priority, but the subtree is still alive. This matters once you start keeping several large screens around. Navigation may feel faster, but you are trading that for retained DOM, memory, and some amount of background work. If we keep 10 expensive pages alive, we get faster navigation back to previously opened pages and preserved state, but we pay for it with memory usage, retained DOM, and potential background renders. I would use activity where preserving state provides a real user-facing benefit. An editor with an unsaved draft is a good example, especially if it integrates a heavyweight
Starting point is 00:13:43 text editor or can contain a large document that the user should not los. On the other hand, a screen whose state does not need to survive and which these are rarely revisits can continue using conditional rendering, activity and preloading. Activity can prepare hidden Ui ahead of time. For example, React may render a tab before the user opens it. There is an important caveat, though not every data loading strategy will start loading during this kind of pre-render. If the request is triggered inside use effect, then the hidden render itself will not trigger the request. This follows directly from the activity lifecycle described earlier. Code inside the effect will not run until the subtree becomes visible in the effect IS created. So this version of fetch
Starting point is 00:14:26 posts does not give us true preloading. With suspense, however, things can work differently. If data loading is part of render and uses a suspense-compatible mechanism, the request can start during the pre-render. For example, using use, the screen itself can remain inside a hidden activity and be wrapped in suspense. Now the sequence is different. Activity hidden down-pointing arrow React pre-renders posts down-pointing arrow posts calls use, posts resource. Get down-pointing arrow data as not ready right pointing arrow loading starts down pointing arrow promise suspends the render through suspense the request starts not because activity somehow runs the effect early. It starts because data loading is part of render and React can pre-render a hidden activity. When the user later opens
Starting point is 00:15:12 the tab, the data may already be available. Activity right pointing arrow visible down pointing arrow posts renders again down pointing arrow data as already loaded down pointing arrow screen appears without waiting for a new request posts resource is intentionally abstract here. In a real application, this code be a framework or library that supports suspense and knows how to cache promises. The implementation of the cache is not the point of this example. The important distinction is, use effect right pointing arrow loading starts only after the effect mounts suspense plus use right pointing arrow loading can start during render so if you add a hidden activity hoping to preload the next screen, but the data still does not start loading and
Starting point is 00:15:53 until the user opens it, the first thing to check E is where the request is actually initiated. Unexpected E2E consequences. There is another practical issue that shows up less in application code and more in E2E tests. Before activity, only one email input exists in the DOM. After activity, the DOM nodes for both tabs can now remain mounted. One of the inputs is hidden, but it still exists. Existing playwright codes such as can now fail in strict mode because the locator matches multiple elements. You can explicitly account for visibility. Playwright supports this kind of filtering, although it generally recommends using a more robust way to uniquely identify the target element whenever possible. An even better approach is to locate the element within the active UI container.
Starting point is 00:16:39 Now the test expresses what the user is actually doing, interacting not with any email field somewhere in the DOM, but with the email field inside the currently visible tab. What I check before reaching for activity. I don't treat activity as a replacement for conditional rendering anymore. Before using it, I usually look for a few things. One, anything happening during render that shouldn't be there? Subscriptions are the obvious example, but not the only one. Two, what should keep running when this UI disappears? If a socket, timer, listener, or other process should outlive the screen, it probably shouldn't be owned by an effect inside that activity. Three, is some cleanup currently happening only because the DOM node gets removed? Video, audio, iframe, and imperative
Starting point is 00:17:24 widgets are worth checking. Four, do I actually want the old state when the user comes back? For a tab, probably. For a, create new item, dialogue, maybe not. And finally, how expensive is this subtree to keep around? Activity can make returning to a screen much nicer, but preserving a screen is not free. Hidden UI can still occupy memory, retain DOM and render in the background. The part one would keep in mind, before using activity, I mostly thought about it as a way to preserve state. After debugging this issue, I think the life cycle difference is more important. It is easy to implicitly rely on this sequence. Render right pointing arrow mount right pointing arrow effect right pointing arrow unmount right pointing arrow cleanup but that is not React's
Starting point is 00:18:09 contract. Render, effects, DOM, and component state have related but distinct life cycles, and activity makes that separation particularly visible. React has been moving in this direction for a long time. Strict mode is specifically designed to find impure renders and asymmetric effects, while concurrent rendering in general requires render to stay free of external side effects. In our case, the subscription bug turned out to be a good example. Activity did not break the cleanup. It simply made a scenario possible that the old hook had never accounted for. So the main question I now ask before using activity is no longer longer. It is. Written by Sergey Levkovich, senior front-end developer at Social Discovery Group.
Starting point is 00:18:51 Thank you for listening to this Hackernoon story, read by artificial intelligence. Visit hackernoon.com to read, write, learn and publish.

There aren't comments yet for this episode. Click on any sentence in the transcript to leave a comment.