Using RxJS with React

Co-locating business logic in React with RxJS

2022-10-099 min read

One of my biggest pet peeves with React is how easy it makes it for me to mix business logic with the UI. It feels innocent at first. "Just a useState will do for now".

Well, that's never the case. Chances are that this value will also be required or set by some other component. So what do we do? We're moving useState higher in the component hierarchy and we pass it around using composition/context.

Then, we might want to fetch some data. React-query might do the job, but we start fragmenting the code. Who initializes the request? Where are we invalidating our queries? What happens if we need dependent queries? Where do all these live, in a messy useEffect hook?

Even the most coherent implementation will have some hacks to comply with Reacts lifecycle. After having been bitten by this for quite some time, I prefer to keep my business logic written outside React.

You can use Redux, or Zustand. Any will do. I prefer to use RxJS! You can still get the same sense of organization, with more fine-grained control over the data flow.

I know that RxJS isn't very loved by a part of the React community. I understand this. If you're going to take anything from this blog let this be this. There's no harm in using Redux, or Zustand. If you're stuck with a SPA, and the URL state is limiting you, by all means, use them. Don't reinvent the wheel. Here's a great article about how tedious it is making the useReducer/useContext combo performant.

Store set-up

The core idea is that all business logic for our feature page (or complex component) lives inside a closure, outside React. This is where all the nitty-gritty of our application logic happens.

Our React app will receive only the bare minimum it needs to render our components. It will not know any transformations. This allows us to limit the complexity in a single file, easily testable without having to bother with the React lifecycle.

We should still write our integration tests to discover any regressions in our React components. We just have the added benefit that we can test in isolation our business logic.

He's a simple case. We don't have to guess who will use orderBy next.

I prefer to export regular Observables instead of BehaviorSubjects. BehaviorSubjects can be updated by using .next(myValue) from the outside world.

Alright, code...

TypeScript
src/app/features/<example-feature>/store.ts
type OrderBy = 'NAME_DESC' | 'NAME_ASC';
 
export function createExampleStore() {
  const setOrderByEvent$ = new Subject<OrderBy>();
  const orderBy$ = setOrderByEvent$.pipe(
    map((orderBy) => orderBy),
    startWith('NAME_ASC'),
    shareReplay(1)
  );
 
  return {
    state: {
      orderBy$: withInitialValue(orderBy$, 'NAME_ASC'),
    },
    actions: {
      setOrderBy: (orderBy: OrderBy) => setOrderByEvent$.next(orderBy),
    },
  };
}

I can hear you saying, "But isn't this very ceremonial? useState is simpler".

const [orderBy, setOrderBy] = useState<OrderBy>('NAME_DESC');

And I agree, it's simpler. But what happens when:

  • We're asked to reset some other filter, every-time orderBy changes. It's irrelevant to our component.
  • And then we need to reuse controls like these in a modal/sidebar? We probably need to change our component hierarchy.

Let's see one case where a value depends on another. Look how elegantly we update the pageIndex. We either set it explicitly, or it resets when the page size updates. No useEffect or mixing side-effects in callbacks like handlePageIndexChange.

No matter who uses these, we ensure that we have separated our design from our application state.

TypeScript
src/app/features/<example-feature>/store.ts
export function createExampleStore() {
  const setPageLimitEvent$ = new Subject<number>();
  const pageLimit$ = setPageLimitEvent$.pipe(
    map((pageLimit) => pageLimit),
    startWith(50),
    shareReplay(1)
  );
 
  const setPageIndexEvent$ = new Subject<number>();
  const pageIndex$ = merge(
    setPageIndexEvent$.pipe(
      map((pageIndex) => pageIndex),
      startWith(0),
      shareReplay(1)
    ),
    setPageLimitEvent$.pipe(mapTo(0)) // Reset the current page, on page-size change
  );
 
  return {
    state: {
      pageIndex: withInitialValue(pageIndex$, 0),
      pageLimit: withInitialValue(pageLimit$, 50),
    },
    actions: {
      setPageIndex: (pageIndex: number) => setPageIndexEvent$.next(pageIndex),
      setPageLimit: (pageLimit: number) => setPageLimitEvent$.next(pageLimit),
    },
  };
}

We can write anything. It's a simple TypeScript file. Let's see a more expressive one.

Here we use discriminated unions to avoid impossible states. Take a look at line 30. We only export a single boolean Observable. Our component doesn't care about the # of selected rows. Just the bare minimum. Keeping our UI flexible.

TypeScript
src/app/features/<example-feature>/store.ts
type RowSelection =
  | {type: 'ADD'; id: number}
  | {type: 'REMOVE'; id: number}
  | {type: 'CLEAR_ALL'};
 
export function createExampleStore() {
  const setSelectedRowEvent$ = new Subject<RowSelection>();
 
  const selectedRows$ = setSelectedRowEvent$.pipe(
    scan((rows: Array<number>, event) => {
      switch (event.type) {
        case 'ADD':
          return [...rows, event.id];
        case 'REMOVE': {
          return rows.filter((rowId) => rowId !== event.id);
        }
        case 'CLEAR_ALL': {
          return [];
        }
        default: {
          return rows;
        }
      }
    }, []),
    startWith([]),
    shareReplay(1)
  );
 
  const exportSelectedRowsEvent$ = new Subject<void>();
  const isExportAvailable$ = selectedRows$.pipe(
    map((rows) => rows.length > 0),
    shareReplay(1)
  );
  const exportRequest$ = combineLatest([
    exportSelectedRowsEvent$,
    isExportAvailable$,
  ]).pipe(
    filter(([, isExportAvailable]) => isExportAvailable),
    map(([selectedRows]) => ({payload: {selectedIds: selectedRows}}))
    // do a network request
  );
 
  return {
    data: {
      exportRequest$: withInitialValue(exportRequest$, {status: 'IDLE'}),
    },
    state: {
      selectedRows: withInitialValue(selectedRows$, [] as Array<number>),
      isExportAvailable: withInitialValue(isExportAvailable$, false),
    },
    actions: {
      selectRow: (id: number) => setSelectedRowEvent$.next({type: 'ADD', id}),
      unselectRow: (id: number) =>
        setSelectedRowEvent$.next({type: 'REMOVE', id}),
      clearAllRows: () => setSelectedRowEvent$.next({type: 'CLEAR_ALL'}),
      //
      exportSelectedRows: () => exportSelectedRowsEvent$.next(),
    },
  };
}

And finally, we can handle network requests with confidence.

  1. Not bothering with useEffect
  2. Not looking for which useQuery initialized that request
  3. Canceling, throttling, debouncing, etc, with ease

Connecting to the React world

Again, we have a simple TypeScript file. Nothing React-specific yet. Let's wire it with our view. We will use Context to pass it around.

React context is a form of Dependency Injection. It's not a state-management solution. Its combination with useState/useReducer makes it one. There's no contradiction here.

Here's how we're going to implement it.

  1. We're rendering a route with React-Router
  2. This entry component will create our store, and re-use its instance until it's unmounted
  3. It will wrap its children with a context provider, passing down the store
  4. Every consumer can fetch that store, and subscribe to the stream it needs.
We won't have extra rerenders. If any other observable than the one we're subscribing to changes, nothing will happen. We're good.

Let's write our one-off boilerplate. Creating a context, typecasting it, and writing our hook for the consumer. The same thing you would do for a useReducer

TypeScript
src/app/features/<example-feature>/storeContext.tsx
import {createContext, useContext, useRef} from 'react';
 
export type ExampleStore = ReturnType<typeof createExampleStore>;
 
export const ExampleStoreContext = createContext({} as ExampleStore);
 
export function useExampleStore() {
  return useContext(ExampleStoreContext);
}

And finally, some React silliness to ensure that we won't override our store. Note that we can pass props to our store initialization. It could be the id of the project we're working on since we normally get that info from the URL.

TypeScript
src/app/features/<example-feature>/storeContext.tsx
import {createContext, useContext, useRef} from 'react';
 
// This imports any of the previous snippets, where a store is created
import {createExampleStore} from './ExampleStore';
 
export type ExampleStore = ReturnType<typeof createExampleStore>;
 
export const ExampleStoreContext = createContext({} as ExampleStore);
 
export function useExampleStore() {
  return useContext(ExampleStoreContext);
}
 
// can pass optional props
export function useCreateExampleStore() {
  const storeRef = useRef<ExampleStore>();
  if (storeRef.current === undefined) {
    // can pass optional props
    storeRef.current = createExampleStore();
  }
  const store = storeRef.current;
  return store;
}

And here's what our page component would look like. A single store that can react to anything.

React
src/app/features/<example-feature>/ExamplePage.tsx
export function ExamplePage() {
  const store = useCreateExampleStore();
 
  return (
    <ExampleStoreContext.Provider value={store}>
      <Layout>
        <OrderBySelector />
        <PageIndexSelector />
        <PageLimitSelector />
        <ExportTrigger />
      </Layout>
      <MyModal />
      <MySidebar />
      <LiterallyAnything />
    </ExampleStoreContext.Provider>
  );
}

Now let's see what a component would look like. It's not different than the Redux equivalent.

export function OrderBySelector() {
  const {state, actions} = useExampleStore();
  const orderBy = useObservable(state.orderBy$);
 
  return (
    <Select value={orderBy} onChange={actions.setOrderBy}>
      <Select.Option value="NAME_ASC">Name ASC</Select.Option>
      <Select.Option value="NAME_DESC">Name DESC</Select.Option>
    </Select>
  );
}
You can also build some Facades. Something like useSortBy and export orderBy and its setter, so that the component won't have full access to the store. I don't like to optimize that much early on, but it's a valid call.

And let's take closer to the useObservable hook, the glue that keeps everything together. Just plain React. Listen to the subscription, grab the value, bring it as state and move on.

TypeScript
observableUtils.ts
import {Observable} from 'rxjs';
import {useState, useEffect} from 'react';
 
type ObservableWrapper<T> = {
  observable: Observable<T>;
  initialValue: T;
};
 
export function withInitialValue<T>(
  observable: Observable<T>,
  initialValue: T
) {
  return Object.freeze({observable, initialValue});
}
 
export function useObservable<T>({
  observable,
  initialValue,
}: ObservableWrapper<T>) {
  const [state, setState] = useState<T>(() => initialValue);
 
  useEffect(() => {
    const sub = observable.subscribe(setState);
    return () => sub.unsubscribe();
  }, [observable]);
 
  return state;
}

There are various 3rd-party implementations like the one from Observable Hooks. That said, I prefer to "hide" the initial value that React needs for that first render. You're free to use a simpler implementation and provide the initial value directly from the component.

Without React-context

With the addition of useSyncExternalSyncStore it's "doable" to omit context. Here's how the updated useObservable would look:

TypeScript
observableUtils.ts
function useObservable<T>(observable: BehaviorSubject<T>): T {
  let observableRef = useRef<BehaviorSubject<T>>(observable);
 
  if (observableRef.current !== observable) {
    observableRef.current = observable;
  }
 
  const subscribe = useCallback((handleStoreChange: VoidFunction) => {
    const subscription = observableRef.current.subscribe(handleStoreChange);
    return () => subscription.unsubscribe();
  }, []);
 
  const getSnapshot = useCallback(() => {
    return observableRef.current.getValue();
  }, []);
  return useSyncExternalStore(subscribe, getSnapshot);
}

There's an obvious "caveat" that we have to use BehaviorSubject instead, as we need access to the "current" value in getSnapshot.

I like my current approach, but if React Suspense requires it, I might revisit it in the future.

Wrapping up

So there's that. I find this approach gives me all the tools I need to tackle anything. No matter how simple any project starts, it has a solid foundation that will tackle any complexity.

By moving my business logic outside React's lifecycle, I can write expressive code without feeling limited.

I wonder how many people realize that React Hooks is really just disconnected, less declarative reactive programming.
Ben Lesh, RxJS Team Lead

If you don't like RxJS, that's fine! Use Redux Toolkit and its new RTK Query package. xState? Be my guest, love it.

My only suggestion is that you move away from the useState/useReducer approach. It doesn't scale. Especially for start-up environments where there are a lot of moving pieces in the UI. Use your time building features, and not optimizing re-renders.


Resources