Hello! Let's dive into our next lesson.
In our previous session, we built a powerful bridge between Zustand and RxJS. We learned how to create an Observable stream from a slice of Zustand state and then used combineLatest to merge it with an external data source. This allowed us to create a derived data stream that reactively updated whenever the store's state or the external data changed.
Today, we'll expand on that concept to address a common challenge in larger applications: coordinating state between multiple, independent stores. Instead of combining a store with an external source, we will synchronize two different Zustand stores using RxJS operators.
Our learning outcome for this lesson is to handle cross-store synchronization and coordination using RxJS operators. We will build a system where a change in one store automatically triggers a calculated update in another, all handled cleanly and declaratively within a reactive pipeline.
1. The Core Pattern: Deriving State from Multiple Sources
The fundamental pattern for today's lesson is the same one we used last time: combining multiple streams to produce a new, derived stream. The key operator for this is combineLatest.
To refresh our memory, let's look at a classic, simple example of this pattern.
Using RxJS and React for Reusable State Management
This article, 'Using RxJS and React for Reusable State Management', provides a perfect, concise example of deriving one piece of state from two others. We'll look at how it creates a fullName$ stream from firstName$ and lastName$ streams.
In the article, find and read the subsection titled 'Derived State With the combineLatest Function'. Focus on how it takes two independent state sources and combines their latest values to create a new, derived stream. This is the essential building block for our cross-store synchronization logic.
As the article demonstrates, combineLatest allows us to create a new state (fullName$) that is reactively dependent on other states (firstName$, lastName$). A change in either of the source streams triggers a new emission from the derived stream. We will now apply this exact principle to coordinate two separate Zustand stores.
2. Scenario: Synchronizing Authentication and Cart Stores
Imagine we're building an e-commerce application. To keep our concerns separated, we have two distinct stores:
AuthStore: Manages the user's authentication status and profile information, including their shipping country.CartStore: Manages the items in the shopping cart, calculates the subtotal, and should also calculate the final total including shipping.
The challenge is that the shipping cost depends on the user's country, which lives in the AuthStore. The CartStore needs to react whenever the user logs in, logs out, or updates their country, and then recalculate the total price.
Here are our two starting stores:
// authStore.ts
import create from 'zustand';
interface User {
name: string;
country: 'USA' | 'CAN' | 'EUR';
}
interface AuthState {
user: User | null;
login: (user: User) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
user: null,
login: (user) => set({ user }),
logout: () => set({ user: null }),
}));
// cartStore.ts
import create from 'zustand';
interface CartItem {
id: number;
name: string;
price: number;
quantity: number;
}
interface CartState {
items: CartItem[];
subtotal: number;
shippingCost: number;
total: number;
addItem: (item: Omit<CartItem, 'quantity'>) => void;
}
export const useCartStore = create<CartState>((set, get) => ({
items: [],
subtotal: 0,
shippingCost: 0,
total: 0,
addItem: (newItem) => { /* ... logic to add item ... */ },
// Note: The calculation logic will be handled externally by RxJS
}));
Our goal is to create a reactive link between useAuthStore.getState().user.country and useCartStore.getState().total.
3. Implementing the Synchronization Logic
The key to a clean architecture here is to create a "coordination layer" where this logic lives. This layer will be responsible for listening to both stores and orchestrating the updates. This keeps the stores themselves simple and focused on holding state, and our React components free from complex business logic.
The following video provides a fantastic demonstration of this architectural pattern. The author uses BehaviorSubjects for state, which are functionally equivalent to the hot observables we create from our Zustand stores using the toStream utility from our last lesson.
React + RxJS = Reactive Global Goodness
Let's watch a segment from 'React + RxJS = Reactive Global Goodness' by Jack Herrington. He demonstrates combining two separate state streams (pokemonWithPower$ and selected$) to create a new derived stream. This is a direct parallel to how we will combine our cart and user data.
Watch from 14:21 to 16:38. Pay close attention to the use of pipe(combineLatest(anotherStream$)) and how the map operator is then used to process the combined data from both sources. This is precisely the pattern we will apply to our stores.
Inspired by the video, let's build our coordination logic. We'll create a new file, store-coordination.ts, to house this logic.
Step 1: Create Streams from Stores
First, we use our toStream utility to get Observables for the specific slices of state we care about.
// store-coordination.ts
import { combineLatest, map, startWith, distinctUntilChanged } from 'rxjs';
import { useAuthStore } from './authStore';
import { useCartStore } from './cartStore';
import { toStream } from './toStream'; // The utility from our previous lesson
// A stream of the user's country, emits null if logged out
const userCountry$ = toStream(
useAuthStore,
(state) => state.user?.country
).pipe(
distinctUntilChanged() // Only emit when the country actually changes
);
// A stream of the cart items
const cartItems$ = toStream(
useCartStore,
(state) => state.items
);
Step 2: Derive Intermediate State (Shipping Cost)
We can create an intermediate stream that translates the country into a shipping cost. This keeps our logic modular.
// store-coordination.ts (continued)
const shippingCost$ = userCountry$.pipe(
map(country => {
if (!country) return 0; // No user, no shipping cost
switch (country) {
case 'USA': return 5;
case 'CAN': return 10;
case 'EUR': return 20;
default: return 25; // International default
}
}),
startWith(0) // Crucial: ensure this stream has an initial value
);
The startWith(0) is important because combineLatest won't emit until all of its input streams have emitted at least once. This ensures we have a default shipping cost from the very beginning.
Step 3: Combine Streams and Close the Loop
Now, we combine the cartItems$ and shippingCost$ streams to calculate the final details. Then, in the final step, we subscribe to this derived stream and use its output to update the CartStore. This "closes the loop" and completes our reactive data flow.
// store-coordination.ts (continued)
// Combine items and shipping to get the final cart details
const cartDetails$ = combineLatest([
cartItems$,
shippingCost$
]).pipe(
map(([items, shipping]) => {
const subtotal = items.reduce((acc, item) => acc + item.price * item.quantity, 0);
const total = subtotal + shipping;
return { subtotal, shippingCost: shipping, total };
})
);
// 4. CLOSE THE LOOP: Subscribe and update the CartStore
// This subscription should be activated once when the app initializes.
export function activateCartCoordination() {
cartDetails$.subscribe(({ subtotal, shippingCost, total }) => {
useCartStore.setState({ subtotal, shippingCost, total });
});
}
By calling activateCartCoordination() once in our application's entry point (e.g., index.tsx), we establish a permanent, reactive link between the two stores.
Now, any change to the cart items OR the user's country will automatically trigger a recalculation and update the CartStore with the correct totals. The React components that consume useCartStore will update automatically, without ever needing to know about the AuthStore or the complex logic connecting them.
Conclusion
Today we've constructed a powerful and scalable pattern for managing dependencies between different state stores. By abstracting the coordination logic into a dedicated reactive layer, we keep our stores and components clean and maintainable.
Key Takeaways:
- Cross-Store Synchronization: RxJS provides an excellent mechanism for coordinating state between modular stores, preventing the need for monolithic state objects.
combineLatestas the Coordinator: This operator is the primary tool for merging state from multiple sources into a new, derived state stream.- The Coordination Layer: It's a best practice to house cross-store logic in a dedicated layer (e.g., a
store-coordination.tsfile), separate from both the stores and the UI components. - Closing the Loop: A reactive system is completed by subscribing to the final derived stream and using its values to update a store via its actions or
setState. This creates a fully automated, unidirectional data flow.
Next Lesson Preview:
We have now explored several foundational architectural patterns for integrating RxJS with state management. In our next module, "Real-World Architectural Patterns," we will shift our focus to applying these patterns to solve common, concrete UI challenges. We'll kick things off by implementing one of the most iconic RxJS use cases: a type-ahead search feature that intelligently handles user input and cancels outdated network requests using the switchMap operator.
Can't find a good explanation? Sign up and we'll make it for you
Sign up