Create your own
Lesson illustration

Building a Subscription Hook: Common Pitfalls

Hello! Welcome back to our course on RxJS in the React Ecosystem.

In our previous lesson, we explored the two primary theoretical challenges of integrating RxJS with React: the risk of memory leaks from unmanaged subscriptions and the subtle problem of UI tearing in modern concurrent rendering.

Today, we will move from theory to practice. Our goal is to implement a basic hook for subscribing to an Observable using the familiar useEffect and useState hooks. By building this pattern from the ground up, we'll see exactly how these pitfalls manifest and why this common approach, while functional, has significant drawbacks. This will set the stage for learning the modern, correct solution in our next lesson.

Let's get started.

Setting the Stage: An RxJS-based Store

To subscribe to something, we first need an Observable. A very common pattern in RxJS-based state management is to use a BehaviorSubject to hold and distribute application state. It's perfect for our needs because it holds a current value and emits it to any new subscribers.

The following video segment demonstrates how to create a simple global store using a BehaviorSubject.

React + RxJS = Reactive Global Goodness

This clip from the video 'React + RxJS = Reactive Global Goodness' by Jack Herrington shows how to set up a simple state store using a BehaviorSubject.

Please watch from 02:54 to 05:12. Focus on how a BehaviorSubject is created, how new values are provided using .next(), and the convention of using a $ suffix for Observables.

Let's create our own simple store for this lesson. Imagine we have a store that manages a user's authentication status.

// store.js
import { BehaviorSubject } from 'rxjs';

// The store holds the current authentication state.
// It starts with `null` (unknown), then could be `{ name: 'User' }` or `false`.
export const authState$ = new BehaviorSubject(null);

// Simulate an async check that runs after 2 seconds
setTimeout(() => {
  // A new value is emitted to all subscribers.
  authState$.next({ name: 'Alex' }); 
}, 2000);

Our goal is to create a React component that subscribes to authState$ and displays the user's name when they are logged in.

The Naive Approach: useEffect and useState

The most intuitive way to connect this store to a React component is to use useEffect to subscribe and useState to hold the value for rendering.

Let's look at a first attempt. The following video clip shows a subscription being created inside useEffect.

React + RxJS = Reactive Global Goodness

This next clip from the same video shows how to subscribe to the BehaviorSubject inside a React component's useEffect hook.

Watch from 05:12 to 06:11. Notice how useEffect is used with an empty dependency array [] to run the subscription logic once when the component mounts.

Following that pattern, our AuthDisplay component would look like this:

// AuthDisplay.jsx
import React, { useState, useEffect } from 'react';
import { authState$ } from './store';

export function AuthDisplay() {
  const [auth, setAuth] = useState(null);

  useEffect(() => {
    console.log('Subscribing to auth state...');
    authState$.subscribe(state => {
      console.log('New auth state received:', state);
      setAuth(state);
    });
  }, []); // Empty dependency array means this runs only on mount

  if (auth === null) {
    return <div>Loading...</div>;
  }

  if (auth === false) {
    return <div>User is not logged in.</div>;
  }

  return <div>Welcome, {auth.name}!</div>;
}

This code will appear to work correctly at first. The component will mount, subscribe, show "Loading...", and then update to "Welcome, Alex!" after two seconds.

However, this implementation has a critical flaw that we discussed in the last lesson. Can you identify it?

Hint Think about the component and the subscription lifecycles. What happens if this `AuthDisplay` component is unmounted from the UI?

The problem is a memory leak. The subscription created in useEffect is never terminated. If the AuthDisplay component were unmounted (e.g., the user navigates to a different page), the subscription would continue to live in memory. If the authState$ emitted another value, the subscription's callback (setAuth(state)) would be called, attempting to update the state of an unmounted component, which triggers a warning from React and is a bug.

The Pitfall in Detail: The Missing Cleanup

The useEffect hook is designed to manage side effects that need to be synchronized with the component's lifecycle. This includes not only starting effects but also cleaning them up.

To understand why cleanup is so crucial, let's watch a short, focused explanation.

All useEffect Mistakes Every Junior React Developer Makes

This video, 'All useEffect Mistakes Every Junior React Developer Makes' by Lama Dev, provides an excellent explanation of the useEffect cleanup function.

Please watch from 09:44 to 12:20. The video uses setInterval as an example, which is conceptually identical to a long-lived Observable subscription. Pay close attention to how returning a function from useEffect allows you to clean up the interval.

As the video demonstrates, any resource created within useEffect that persists (like a timer, an event listener, or an RxJS subscription) must be destroyed in the cleanup function.

The Correction: Unsubscribing on Unmount

To fix the memory leak, we must save the Subscription object returned by the .subscribe() call and then call its .unsubscribe() method in the cleanup function that we return from useEffect.

The correct implementation looks like this. Notice the return statement inside the useEffect.

// AuthDisplay.jsx (Corrected)
import React, { useState, useEffect } from 'react';
import { authState$ } from './store';

export function AuthDisplay() {
  const [auth, setAuth] = useState(null);

  useEffect(() => {
    console.log('Subscribing to auth state...');
    const subscription = authState$.subscribe(state => {
      console.log('New auth state received:', state);
      setAuth(state);
    });

    // This cleanup function is called when the component unmounts
    return () => {
      console.log('Unsubscribing from auth state!');
      subscription.unsubscribe();
    };
  }, []);

  // ... render logic remains the same
  if (auth === null) return <div>Loading...</div>;
  if (auth === false) return <div>User is not logged in.</div>;
  return <div>Welcome, {auth.name}!</div>;
}

This pattern is demonstrated clearly in many online resources. The following article provides a concise example of this exact pattern for fetching data.

Efficient Data Fetching in React with RxJS Strategies

The article 'Efficient Data Fetching in React with RxJS Strategies' shows this exact pattern in its UserComponent example.

Read the section 'Integrating RxJS with React Components'. Focus on the code block for UserComponent. It perfectly illustrates subscribing in useEffect and unsubscribing in the cleanup function.

With this change, our component is now free of memory leaks. The subscription's lifecycle is correctly tied to the component's lifecycle.

The Second Pitfall: Vulnerability to Tearing

We've solved the memory leak, but what about the second problem we discussed: UI tearing?

Our corrected code is still vulnerable to it. Let's quickly recap why:

  1. Concurrent Rendering: React can pause a render partway through, handle a higher-priority task, and then resume rendering.
  2. External Store: Our authState$ is an external store. Its value can change at any time, independently of React's render cycle.
  3. The Race Condition: If authState$ emits a new value during a paused render, components rendered before the pause could get an old value from their useState, while components rendered after the pause could get a new value. This results in a "torn" and inconsistent UI.

The useEffect + useState pattern cannot prevent this. useEffect runs after the render has already been calculated and committed to the screen. It can't ensure that all components in a single render pass read the exact same value from the external store.

While it's difficult to reproduce tearing with a simple example, it's crucial to understand that this pattern is fundamentally not safe in modern React. It's a pitfall waiting to happen in a complex application.

Conclusion

In this lesson, we put theory into practice by building the most common and basic pattern for connecting an RxJS Observable to a React component.

Our key takeaways are:

  • The useEffect + useState pattern is the intuitive approach for subscribing to an Observable.
  • Pitfall 1: Without a cleanup function that calls subscription.unsubscribe(), this pattern creates memory leaks.
  • Correction: The useEffect cleanup function is the correct place to unsubscribe, tying the subscription lifecycle to the component lifecycle.
  • Pitfall 2: Even with proper cleanup, this pattern is not safe in concurrent React because it is vulnerable to UI tearing.

You now understand not just that there are pitfalls, but how they manifest in code. You've implemented the "classic" approach and seen its limitations.

Next Steps

In our next lesson, we will solve these problems definitively. We will refactor our subscription logic into a reusable custom hook using useSyncExternalStore, a modern React API designed specifically to create tear-free, efficient subscriptions to external data sources like our RxJS store.

Can't find a good explanation? Sign up and we'll make it for you

Sign up