Welcome back, future architectural wizard! In our journey through modern React system design, we’ve explored rendering strategies and the fascinating world of microfrontends. Now, it’s time to tackle one of the most critical and often challenging aspects of any large-scale application: state management.

As your React applications grow, managing data that needs to be shared across many components, or even across entirely separate microfrontends, can quickly become a tangled mess. We’ve all started with useState and useReducer for local component state, which are fantastic, but what happens when data needs to travel deeply through the component tree, or be accessible by components that aren’t direct siblings or parents? This chapter will equip you with the knowledge and tools to manage state gracefully, efficiently, and scalably, ensuring your applications remain performant and maintainable.

By the end of this chapter, you’ll understand the core challenges of global state, master React’s built-in Context API, explore powerful modern state management libraries like Zustand and Jotai, and learn crucial strategies for defining and respecting state boundaries, especially within a microfrontend architecture. Let’s dive in and untangle the complexities of large-scale state!

The Challenge of Global State

Imagine you’re building a multi-tenant SaaS dashboard. You have user authentication status, tenant-specific settings (like theme preferences), and real-time notifications. These pieces of data need to be accessible by various components scattered throughout your application – the header, sidebar, dashboard widgets, and user profile page.

The Problem: Prop Drilling

Without a dedicated global state solution, you might find yourself passing props down, down, and further down through intermediate components that don’t even use the data themselves. This is known as “prop drilling.”

Let’s visualize this common scenario:

graph TD A["App Component"] --> B["Layout Component"] B --> C["Sidebar"] B --> D["Main Content"] C --> E["User Avatar"] D --> F["Dashboard Widget"] F --> G["Nested Component"] subgraph PropDrillingPath A --">> User Data, Theme" --> B B --">> User Data, Theme" --> C B --">> User Data" --> D C --">> User Data" --> E D --">> User Data" --> F F --">> User Data" --> G end

What do you notice? Components B, C, D, and F are merely conduits, passing User Data and Theme down to their children. This creates several problems:

  • Code Complexity: It makes your component signatures bloated and harder to read.
  • Refactoring Headaches: If a component deep in the tree needs a new piece of data, you might have to modify many intermediate components.
  • Performance Concerns: While React is smart, unnecessary prop changes can trigger re-renders, especially if not carefully optimized.

State Boundaries: A Guiding Principle

Before we jump into solutions, let’s establish a critical mental model: state boundaries. Not all state needs to be global. In fact, most state should be as local as possible. A “state boundary” refers to the scope within which a particular piece of state is relevant and managed.

  • Component Local State (useState, useReducer): For state only relevant to a single component and its immediate children. This is your default.
  • Shared Component State (Lifted State): When two sibling components need to share state, lift it up to their closest common ancestor.
  • Global Application State: For data truly needed across many disparate parts of the application (e.g., authenticated user, app-wide settings, notifications). This is where our solutions come in.
  • Microfrontend Global State: For specific, minimal data shared between independent microfrontends. This requires careful consideration to avoid tight coupling.

The goal is to keep state within the smallest possible boundary, moving it to a wider boundary only when necessary, and always with a clear understanding of the tradeoffs.

React Context API: The Built-in Solution

React’s Context API provides a way to share values like user data, themes, or preferred languages between components without explicitly passing props through every level of the tree. It’s built right into React, making it a powerful tool for certain types of global state.

What it is

Context allows you to create a “tunnel” through your component tree. A Provider component injects a value, and any Consumer (or useContext hook) anywhere deeper in the tree can read that value.

Why it’s important

It elegantly solves prop drilling for data that is not updated frequently, such as a user’s logged-in status, application theme, or locale. It’s simple to set up and doesn’t require any external libraries.

How it works: createContext, Provider, and useContext

Let’s break down the three main pieces:

  1. createContext: This function creates a Context object. When React renders a component that subscribes to this Context object, it will read the current context value from the closest matching Provider above it in the tree.

    // src/contexts/AuthContext.js
    import { createContext } from 'react';
    
    // We provide a default value here. This value is used when a component
    // tries to consume the context without a matching Provider above it.
    // For a complex object, a default of null or an empty object with expected shape is common.
    export const AuthContext = createContext(null);
    

    Here, AuthContext is our tunnel. The default null means if you try to useContext(AuthContext) without a provider, you’ll get null.

  2. Provider: Every Context object comes with a Provider React component. It allows consuming components to subscribe to context changes. The Provider accepts a value prop to be passed to its descendant components.

    // src/contexts/AuthContext.js (continued)
    import { createContext, useState, useEffect } from 'react';
    
    export const AuthContext = createContext(null);
    
    // This component will wrap parts of your application that need access to auth state.
    export function AuthProvider({ children }) {
        const [user, setUser] = useState(null);
        const [isLoading, setIsLoading] = useState(true);
    
        useEffect(() => {
            // Simulate checking for a logged-in user, e.g., from localStorage or an API
            const storedUser = localStorage.getItem('currentUser');
            if (storedUser) {
                setUser(JSON.parse(storedUser));
            }
            setIsLoading(false);
        }, []);
    
        const login = (userData) => {
            setUser(userData);
            localStorage.setItem('currentUser', JSON.stringify(userData));
        };
    
        const logout = () => {
            setUser(null);
            localStorage.removeItem('currentUser');
        };
    
        // The 'value' prop is what will be accessible to consumers.
        const contextValue = {
            user,
            isLoading,
            login,
            logout,
            isAuthenticated: !!user, // A derived state for convenience
        };
    
        return (
            <AuthContext.Provider value={contextValue}>
                {children}
            </AuthContext.Provider>
        );
    }
    

    The AuthProvider wraps its children and makes contextValue available to all of them.

  3. useContext: This is a React Hook that lets you read the context value. It takes a Context object (the value returned from createContext) and returns the current context value for that context.

    // src/components/UserProfile.js
    import React, { useContext } from 'react';
    import { AuthContext } from '../contexts/AuthContext';
    
    function UserProfile() {
        // We consume the value provided by the AuthProvider
        const { user, isAuthenticated, logout } = useContext(AuthContext);
    
        if (!isAuthenticated) {
            return <p>Please log in.</p>;
        }
    
        return (
            <div>
                <h3>Welcome, {user.name}!</h3>
                <p>Email: {user.email}</p>
                <button onClick={logout}>Log Out</button>
            </div>
        );
    }
    
    export default UserProfile;
    

    With useContext, UserProfile can directly access user, isAuthenticated, and logout without any props being passed down from its parents. Pretty neat, right?

Limitations of Context API

While powerful, Context has a significant limitation for large-scale applications with frequent updates:

  • Re-renders all consumers: When the value prop of a Provider changes, all components consuming that context will re-render, even if they only use a small part of the value that didn’t change. This can lead to performance bottlenecks if not managed carefully, especially for high-frequency updates.
  • Not optimized for complex logic: Managing complex state logic (like async operations, derived state, or multiple interdependent pieces of state) within a single AuthProvider can become cumbersome.

For these reasons, while Context is excellent for “static” or infrequently updated global data, many large applications opt for external state management libraries for more dynamic and performance-critical state.

Modern External State Management Libraries (2026 Perspective)

The React ecosystem offers a rich variety of state management libraries, each with its own philosophy and strengths. As of 2026, the trend favors lightweight, performant, and developer-friendly solutions that integrate seamlessly with React Hooks. Libraries like Redux Toolkit remain robust choices for very complex, enterprise-level applications needing strict predictability, but for many projects, simpler, more modern alternatives have gained significant traction.

We’ll focus on two popular choices that represent different approaches to modern state management: Zustand and Jotai.

Why use them?

These libraries address the limitations of Context API by offering:

  • Optimized Re-renders: They often employ subscription mechanisms that only re-render components when the specific piece of state they are consuming changes, not the entire store.
  • Simpler API: Many are designed to be more concise and “React-idiomatic” with hook-based APIs.
  • Better Developer Experience: Often provide built-in patterns for async operations, derived state, and sometimes dev tools.
  • No Provider Hell: Some libraries, like Zustand, don’t require wrapping your entire application in a Provider, simplifying your component tree.

Zustand: The Bear Necessities (Version ~4.5.0)

Zustand is a small, fast, and scalable bear-necessities state management solution. It’s often praised for its simplicity and performance. It doesn’t require a Context Provider, making it feel very much like a global useState.

  • What it is: A minimalistic, hook-based state management library that uses a single store per state slice.
  • Why it’s popular: Extremely simple API, fast, optimized for re-renders by only updating components that subscribe to changed state, and has excellent TypeScript support. It feels very natural for React developers.
  • How it works: You create a store using the create function, and then components consume parts of that store using a custom hook generated by Zustand.

Let’s set up a store for tenant-specific settings like theme and notifications.

Step-by-Step with Zustand

  1. Install Zustand:

    npm install zustand@^4.5.0
    # or
    yarn add zustand@^4.5.0
    

    Note: Always check the official Zustand documentation for the absolute latest stable version and installation instructions as of 2026.

  2. Create a Zustand Store for Settings: Let’s create src/stores/useSettingsStore.js.

    // src/stores/useSettingsStore.js
    import { create } from 'zustand';
    
    // Define the shape of our settings state
    const initialState = {
        theme: 'light',
        notificationsEnabled: true,
        notificationCount: 0,
    };
    
    // The 'create' function returns a hook that you can use in your components.
    // It takes a function that receives 'set' and 'get' functions.
    // 'set' is used to update the state, 'get' is used to read the current state.
    export const useSettingsStore = create((set, get) => ({
        ...initialState, // Spread our initial state
    
        // Action to toggle the theme
        toggleTheme: () => set((state) => ({
            theme: state.theme === 'light' ? 'dark' : 'light'
        })),
    
        // Action to toggle notifications
        toggleNotifications: () => set((state) => ({
            notificationsEnabled: !state.notificationsEnabled
        })),
    
        // Action to increment notification count
        incrementNotificationCount: (amount = 1) => set((state) => ({
            notificationCount: state.notificationCount + amount
        })),
    
        // Action to reset settings to initial state
        resetSettings: () => set(initialState),
    }));
    

    Explanation:

    • create((set, get) => ({...})): This is the core of a Zustand store. The function you pass to create defines your state and actions.
    • set: A function to update the store’s state. You pass it a function that receives the current state and returns the new partial state. This is similar to useState’s updater function.
    • get: A function to read the current state outside of the set function, useful for deriving new state or performing actions based on current state.
    • We define initialState and spread it. Then, we define actions (toggleTheme, toggleNotifications, etc.) as functions that use set to modify the state.
  3. Consume the Store in Components: Now, any component can directly import useSettingsStore and access its state or actions.

    // src/components/SettingsPanel.js
    import React from 'react';
    import { useSettingsStore } from '../stores/useSettingsStore';
    
    function SettingsPanel() {
        // Select specific pieces of state and actions using a selector function.
        // This is crucial for performance: only components using 'theme' will re-render
        // if 'theme' changes, not if 'notificationCount' changes.
        const theme = useSettingsStore((state) => state.theme);
        const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled);
        const toggleTheme = useSettingsStore((state) => state.toggleTheme);
        const toggleNotifications = useSettingsStore((state) => state.toggleNotifications);
        const resetSettings = useSettingsStore((state) => state.resetSettings);
    
        return (
            <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px' }}>
                <h4>App Settings</h4>
                <p>Current Theme: **{theme}**</p>
                <button onClick={toggleTheme}>Toggle Theme</button>
    
                <p>Notifications: **{notificationsEnabled ? 'Enabled' : 'Disabled'}**</p>
                <button onClick={toggleNotifications}>Toggle Notifications</button>
    
                <br /><br />
                <button onClick={resetSettings}>Reset All Settings</button>
            </div>
        );
    }
    
    export default SettingsPanel;
    
    // src/components/Header.js
    import React from 'react';
    import { useSettingsStore } from '../stores/useSettingsStore';
    import { AuthContext } from '../contexts/AuthContext'; // Using AuthContext too
    
    function Header() {
        // We can select multiple values from the store at once.
        // The `shallow` comparison ensures re-render only if *any* of these values change.
        const { theme, notificationCount } = useSettingsStore(
            (state) => ({
                theme: state.theme,
                notificationCount: state.notificationCount,
            }),
            (oldState, newState) => oldState.theme === newState.theme && oldState.notificationCount === newState.notificationCount
        );
    
        // Or simply:
        // const theme = useSettingsStore((state) => state.theme);
        // const notificationCount = useSettingsStore((state) => state.notificationCount);
    
        const { user, isAuthenticated, logout } = React.useContext(AuthContext);
    
        return (
            <header style={{ background: theme === 'dark' ? '#333' : '#f0f0f0', color: theme === 'dark' ? '#fff' : '#333', padding: '10px 20px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
                <h2>My SaaS Dashboard</h2>
                <div style={{ display: 'flex', alignItems: 'center' }}>
                    {isAuthenticated ? (
                        <>
                            <span>Welcome, {user.name}!</span>
                            {notificationCount > 0 && <span style={{ marginLeft: '10px', background: 'red', color: 'white', borderRadius: '50%', padding: '2px 8px' }}>{notificationCount}</span>}
                            <button onClick={logout} style={{ marginLeft: '15px' }}>Log Out</button>
                        </>
                    ) : (
                        <span>Please Log In</span>
                    )}
                    <SettingsPanel /> {/* We'll place it here for demonstration */}
                </div>
            </header>
        );
    }
    
    export default Header;
    

    Explanation:

    • useSettingsStore((state) => state.theme): This is a selector function. It tells Zustand exactly which part of the state this component cares about. If theme changes, this component re-renders. If notificationCount changes, it doesn’t re-render (unless it also selects notificationCount). This is the magic behind Zustand’s performance.
    • You can select multiple values or even the entire state object if needed, but it’s best practice to be granular.
    • Notice how Header and SettingsPanel directly import useSettingsStore – no providers needed!

Jotai / Recoil: Atom-Based State (Jotai Version ~2.7.0)

Jotai and Recoil represent a paradigm shift towards “atom-based” state management. Inspired by React’s concurrent mode capabilities, they offer extremely granular control over state and re-renders, making them highly performant for complex scenarios.

  • What they are: Libraries that provide “atoms” – independent, isolated pieces of state that can be read from and written to by any component.
  • Why they’re powerful: Fine-grained updates mean only the components consuming a specific atom re-render when that atom changes. They handle derived state (computed from other atoms) and asynchronous operations very elegantly.
  • How they work (briefly): You define atoms, which are like individual pieces of state. Components then use hooks like useAtom to read and write to these atoms.

We’ll briefly touch on Jotai as an example due to its lightweight nature and strong community adoption.

Step-by-Step with Jotai

  1. Install Jotai:

    npm install jotai@^2.7.0
    # or
    yarn add jotai@^2.7.0
    

    Note: Always check the official Jotai documentation for the absolute latest stable version and installation instructions as of 2026.

  2. Define Atoms: Jotai requires a Provider at the root of your application, but it’s often a single, simple wrapper. Let’s create src/stores/jotaiAtoms.js.

    // src/stores/jotaiAtoms.js
    import { atom } from 'jotai';
    
    // Define a basic atom for a counter
    export const countAtom = atom(0);
    
    // Define a read-only atom (derived state)
    // This atom will automatically update when countAtom changes
    export const doubledCountAtom = atom((get) => get(countAtom) * 2);
    
    // Define a read-write atom for a text input
    export const textAtom = atom('hello');
    
    // Define an atom with async read/write capabilities (e.g., fetching data)
    export const userProfileAtom = atom(
        null, // Initial state
        async (get, set, userId) => { // Write function for async updates
            if (!userId) {
                set(userProfileAtom, null);
                return;
            }
            set(userProfileAtom, { name: 'Loading...', email: '' }); // Set loading state
            try {
                // Simulate API call
                const response = await new Promise(resolve => setTimeout(() => {
                    resolve({ id: userId, name: `User ${userId}`, email: `user${userId}@example.com` });
                }, 500));
                set(userProfileAtom, response);
            } catch (error) {
                console.error("Failed to fetch user:", error);
                set(userProfileAtom, null);
            }
        }
    );
    

    Explanation:

    • atom(initialValue): Creates a basic read-write atom.
    • atom((get) => ...): Creates a read-only atom whose value is derived from other atoms. It automatically re-calculates when its dependencies change.
    • atom(initialValue, (get, set, arg) => ...): Creates a read-write atom where the write function can perform complex logic, including asynchronous operations.
  3. Provide Jotai’s Store (if using multiple stores or advanced features): For simple cases, Jotai often works without an explicit Provider if all atoms are defined globally. However, for features like debugging, custom store instances, or specific integrations, you might wrap your app:

    // src/App.js (partial)
    import React from 'react';
    import { Provider } from 'jotai'; // Import Jotai's Provider
    
    function App() {
        return (
            <Provider> {/* Wrap your application with Jotai's Provider */}
                {/* ... other components */}
            </Provider>
        );
    }
    export default App;
    
  4. Consume Atoms in Components:

    // src/components/JotaiCounter.js
    import React from 'react';
    import { useAtom } from 'jotai';
    import { countAtom, doubledCountAtom } from '../stores/jotaiAtoms';
    
    function JotaiCounter() {
        // useAtom returns [value, setValue] similar to useState
        const [count, setCount] = useAtom(countAtom);
        const [doubledCount] = useAtom(doubledCountAtom); // Read-only atom
    
        return (
            <div style={{ padding: '20px', border: '1px solid #ccc', borderRadius: '8px', marginTop: '20px' }}>
                <h4>Jotai Counter Example</h4>
                <p>Count: {count}</p>
                <p>Doubled Count: {doubledCount}</p>
                <button onClick={() => setCount((c) => c + 1)}>Increment</button>
                <button onClick={() => setCount(0)} style={{ marginLeft: '10px' }}>Reset</button>
            </div>
        );
    }
    
    export default JotaiCounter;
    

    Explanation:

    • useAtom(countAtom): This hook subscribes the component to countAtom. When countAtom’s value changes, only this component (and others consuming countAtom) will re-render.
    • Derived atoms like doubledCountAtom automatically update when their source atoms change.

Which one to choose?

  • React Context: Best for infrequent, application-wide data like themes, user authentication (as we used it). Simple and built-in.
  • Zustand: Excellent for most general-purpose global state, especially when you want simplicity, performance, and a hook-like API without a Provider. Great for dashboards, forms, and moderate complexity.
  • Jotai/Recoil: Powerful for highly granular state, complex derived state, and scenarios requiring maximum performance optimization, especially when dealing with concurrent rendering features. Good for high-frequency updates, complex data graphs, and a more “functional reactive” style.

State Boundaries in Microfrontends

Managing state across microfrontends is where things get truly architectural. The core principle of microfrontends is independence. This means each microfrontend should ideally manage its own state. However, reality often dictates that some minimal shared state is necessary (e.g., the currently logged-in user, the active tenant ID, global notifications).

The challenge is to share this minimal state without reintroducing tight coupling, which defeats the purpose of microfrontends.

Strategies for Microfrontend State Sharing

  1. Shared Global State via Host (Module Federation): If you’re using Webpack Module Federation (as discussed in Chapter 5), the host application can expose a global state store or a React Context Provider that remote microfrontends can consume. This is often the most direct way to share React-specific state.

    • How it works: The host defines a Zustand store or a Context. The microfrontend then declares this store/context as an external or shared dependency, consuming the same instance from the host.

    • Tradeoffs:

      • Direct React integration: Feels natural for React components.
      • Type safety: Can maintain type safety for shared state.
      • Coupling: The microfrontend is now dependent on the host providing that specific state. Changes to the host’s store shape can break remotes.
      • Version management: Requires careful versioning of the shared state interface.
    • Production Failure Story: A team used Module Federation to share a large, complex Redux store from the host. Over time, microfrontends started dispatching actions directly into the host’s store and relying on many slices. When the host needed to refactor its Redux store for performance, it caused cascading build failures and runtime errors across dozens of microfrontends, leading to a multi-day outage during deployment. Lesson: Only share minimal, stable interfaces, ideally read-only or with very few, well-defined actions.

  2. Event Bus (Custom Events): A more decoupled approach is to use a browser’s custom events or a lightweight event emitter library.

    • How it works: Microfrontends dispatch custom events (e.g., window.dispatchEvent(new CustomEvent('tenantChange', { detail: { tenantId: 'abc' } }))) when something relevant changes. Other microfrontends listen for these events (window.addEventListener('tenantChange', handler)).

    • Tradeoffs:

      • High decoupling: Microfrontends don’t directly depend on each other’s internal state.
      • Framework agnostic: Works across different frontend frameworks.
      • No type safety (by default): Events are strings, details are any. Requires careful documentation and runtime validation.
      • Debugging complexity: Harder to trace state flow than direct store access.
    • Example (Conceptual):

    // Host Application (or Microfrontend that initiates a change)
    function dispatchTenantChange(tenantId) {
        console.log(`Host dispatching tenant change: ${tenantId}`);
        window.dispatchEvent(new CustomEvent('app:tenant-changed', {
            detail: { tenantId: tenantId }
        }));
    }
    
    // Call this when tenant changes, e.g., after login
    // dispatchTenantChange('tenant-001');
    
    // Microfrontend (listening for changes)
    import React, { useEffect, useState } from 'react';
    
    function TenantSpecificWidget() {
        const [currentTenantId, setCurrentTenantId] = useState(null);
    
        useEffect(() => {
            const handleTenantChange = (event) => {
                console.log('Microfrontend received tenant change event:', event.detail.tenantId);
                setCurrentTenantId(event.detail.tenantId);
            };
    
            window.addEventListener('app:tenant-changed', handleTenantChange);
    
            // Clean up the event listener when the component unmounts
            return () => {
                window.removeEventListener('app:tenant-changed', handleTenantChange);
            };
        }, []); // Empty dependency array means this effect runs once on mount
    
        if (!currentTenantId) {
            return <p>Waiting for tenant selection...</p>;
        }
    
        return (
            <div style={{ border: '1px dashed blue', padding: '10px', marginTop: '10px' }}>
                <h4>Widget for Tenant: {currentTenantId}</h4>
                <p>Displaying data specific to this tenant.</p>
            </div>
        );
    }
    export default TenantSpecificWidget;
    
  3. URL Query Parameters / Local Storage / IndexedDB: For simpler, non-real-time data, or for persistence across refreshes, these browser APIs can be used.

    • How it works: Data is stored in the URL (e.g., ?tenantId=abc), localStorage, or IndexedDB. Microfrontends read from and write to these shared browser resources.
    • Tradeoffs:
      • Highly decoupled: No direct code dependency.
      • Persistence: Data survives page refreshes.
      • No real-time updates (for Local Storage/IndexedDB): Other tabs/microfrontends won’t automatically react to changes without polling or storage events.
      • Security concerns: Sensitive data should not be stored in URL or localStorage.
      • Limited data types/size: localStorage only stores strings.

The Golden Rule for Microfrontend State

Share as little as possible, and only through stable, well-defined interfaces. Prioritize events or URL parameters for maximum decoupling. If direct state sharing is absolutely necessary (e.g., via Module Federation), ensure the shared state is minimal, immutable, and versioned strictly to prevent breaking changes.

Step-by-Step Implementation: Enhancing the SaaS Dashboard

Let’s put these concepts into practice. We’ll refine our multi-tenant SaaS dashboard (or a new small app if you’re starting fresh) to manage user authentication with React Context and tenant-specific settings with Zustand. We’ll also conceptually add a microfrontend-like widget that reacts to tenant changes using an event bus.

Project Setup (if you don’t have one)

If you don’t have a project from previous chapters, quickly set up a new React app:

npx create-react-app my-dashboard-app --template typescript --use-npm
cd my-dashboard-app
npm start

Note: create-react-app is still a valid way to start simple React apps, though modern frameworks like Next.js or Remix are preferred for production apps that need SSR/SSG. For this exercise, a basic CRA app is sufficient.

1. Implement Auth State using React Context

We already outlined the code for AuthContext.js and UserProfile.js. Let’s integrate it.

Create src/contexts/AuthContext.js:

// src/contexts/AuthContext.js
import { createContext, useState, useEffect } from 'react';

export const AuthContext = createContext(null);

export function AuthProvider({ children }) {
    const [user, setUser] = useState(null);
    const [isLoading, setIsLoading] = useState(true);

    useEffect(() => {
        // Simulate checking for a logged-in user, e.g., from localStorage or an API
        const storedUser = localStorage.getItem('currentUser');
        if (storedUser) {
            setUser(JSON.parse(storedUser));
        }
        setIsLoading(false);
    }, []);

    const login = (userData) => {
        setUser(userData);
        localStorage.setItem('currentUser', JSON.stringify(userData));
        // Simulate event for microfrontends on tenant/user change
        window.dispatchEvent(new CustomEvent('app:tenant-changed', {
            detail: { tenantId: userData.tenantId, userId: userData.id }
        }));
    };

    const logout = () => {
        setUser(null);
        localStorage.removeItem('currentUser');
        // Simulate event for microfrontends on user logout
        window.dispatchEvent(new CustomEvent('app:tenant-changed', {
            detail: { tenantId: null, userId: null }
        }));
    };

    const contextValue = {
        user,
        isLoading,
        login,
        logout,
        isAuthenticated: !!user,
    };

    return (
        <AuthContext.Provider value={contextValue}>
            {children}
        </AuthContext.Provider>
    );
}

Create src/components/UserProfile.js:

// src/components/UserProfile.js
import React, { useContext } from 'react';
import { AuthContext } from '../contexts/AuthContext';

function UserProfile() {
    const { user, isAuthenticated, logout } = useContext(AuthContext);

    if (!isAuthenticated) {
        return (
            <div style={{ border: '1px solid #eee', padding: '15px', borderRadius: '8px', background: '#f9f9f9' }}>
                <p>Not logged in.</p>
                <button onClick={() => {
                    // Simulate a login
                    const dummyUser = { id: 'usr-123', name: 'Alice', email: '[email protected]', tenantId: 'tenant-A' };
                    // The login function from AuthContext will also dispatch our custom event
                    useContext(AuthContext).login(dummyUser);
                }}>Login as Alice (Tenant A)</button>
                <button onClick={() => {
                    const dummyUser = { id: 'usr-456', name: 'Bob', email: '[email protected]', tenantId: 'tenant-B' };
                    useContext(AuthContext).login(dummyUser);
                }} style={{ marginLeft: '10px' }}>Login as Bob (Tenant B)</button>
            </div>
        );
    }

    return (
        <div style={{ border: '1px solid #eee', padding: '15px', borderRadius: '8px', background: '#e6ffe6' }}>
            <h3>Welcome, {user.name}!</h3>
            <p>Email: {user.email}</p>
            <p>Tenant ID: {user.tenantId}</p>
            <button onClick={logout}>Log Out</button>
        </div>
    );
}

export default UserProfile;

2. Implement Tenant-Specific Settings with Zustand

Now for Zustand.

Install Zustand:

npm install zustand@^4.5.0

Create src/stores/useSettingsStore.js:

// src/stores/useSettingsStore.js
import { create } from 'zustand';

const initialState = {
    theme: 'light',
    notificationsEnabled: true,
    notificationCount: 0,
};

export const useSettingsStore = create((set, get) => ({
    ...initialState,

    toggleTheme: () => set((state) => ({
        theme: state.theme === 'light' ? 'dark' : 'light'
    })),

    toggleNotifications: () => set((state) => ({
        notificationsEnabled: !state.notificationsEnabled
    })),

    incrementNotificationCount: (amount = 1) => set((state) => ({
        notificationCount: state.notificationCount + amount
    })),

    resetNotificationCount: () => set({ notificationCount: 0 }), // New action
    resetSettings: () => set(initialState),
}));

Create src/components/SettingsPanel.js:

// src/components/SettingsPanel.js
import React from 'react';
import { useSettingsStore } from '../stores/useSettingsStore';

function SettingsPanel() {
    const theme = useSettingsStore((state) => state.theme);
    const notificationsEnabled = useSettingsStore((state) => state.notificationsEnabled);
    const toggleTheme = useSettingsStore((state) => state.toggleTheme);
    const toggleNotifications = useSettingsStore((state) => state.toggleNotifications);
    const resetSettings = useSettingsStore((state) => state.resetSettings);

    return (
        <div style={{ padding: '15px', border: '1px solid #ddd', borderRadius: '8px', background: '#fff', marginTop: '10px' }}>
            <h4>App Settings</h4>
            <p>Current Theme: **{theme}**</p>
            <button onClick={toggleTheme}>Toggle Theme</button>

            <p>Notifications: **{notificationsEnabled ? 'Enabled' : 'Disabled'}**</p>
            <button onClick={toggleNotifications}>Toggle Notifications</button>

            <br /><br />
            <button onClick={resetSettings}>Reset All Settings</button>
        </div>
    );
}

export default SettingsPanel;

3. Integrate into App.js and Header.js

Now, let’s bring it all together in our main application file and a Header component.

Create src/components/Header.js:

// src/components/Header.js
import React, { useContext } from 'react';
import { useSettingsStore } from '../stores/useSettingsStore';
import { AuthContext } from '../contexts/AuthContext';
import SettingsPanel from './SettingsPanel'; // Import SettingsPanel

function Header() {
    const { theme, notificationCount, resetNotificationCount } = useSettingsStore(
        (state) => ({
            theme: state.theme,
            notificationCount: state.notificationCount,
            resetNotificationCount: state.resetNotificationCount,
        }),
        // Zustand's shallow comparison for multiple selectors
        (oldState, newState) => oldState.theme === newState.theme &&
                               oldState.notificationCount === newState.notificationCount
    );

    const { user, isAuthenticated } = useContext(AuthContext);

    return (
        <header style={{
            background: theme === 'dark' ? '#333' : '#f0f0f0',
            color: theme === 'dark' ? '#fff' : '#333',
            padding: '10px 20px',
            display: 'flex',
            justifyContent: 'space-between',
            alignItems: 'center',
            boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
        }}>
            <h2 style={{ margin: 0 }}>My SaaS Dashboard</h2>
            <div style={{ display: 'flex', alignItems: 'center' }}>
                {isAuthenticated ? (
                    <>
                        <span>Welcome, {user.name}!</span>
                        {notificationCount > 0 && (
                            <span
                                style={{
                                    marginLeft: '10px',
                                    background: 'red',
                                    color: 'white',
                                    borderRadius: '50%',
                                    padding: '4px 9px',
                                    fontSize: '0.8em',
                                    cursor: 'pointer'
                                }}
                                onClick={resetNotificationCount}
                                title="Click to clear notifications"
                            >
                                {notificationCount}
                            </span>
                        )}
                        <span style={{ marginLeft: '15px' }}>Theme: {theme}</span>
                    </>
                ) : (
                    <span>Please Log In</span>
                )}
            </div>
        </header>
    );
}

export default Header;

Modify src/App.js:

// src/App.js
import React from 'react';
import './App.css'; // Assuming you have some basic CSS
import { AuthProvider } from './contexts/AuthContext';
import Header from './components/Header';
import UserProfile from './components/UserProfile';
import SettingsPanel from './components/SettingsPanel'; // We'll put it here for now
import TenantSpecificWidget from './components/TenantSpecificWidget'; // Our simulated microfrontend

function App() {
    return (
        <AuthProvider> {/* AuthContext Provider wraps the entire app */}
            <div className="App" style={{ fontFamily: 'Arial, sans-serif' }}>
                <Header />
                <main style={{ padding: '20px' }}>
                    <h1>Dashboard Content</h1>
                    <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '20px' }}>
                        <div>
                            <h2>User Area</h2>
                            <UserProfile />
                        </div>
                        <div>
                            <h2>Global Settings</h2>
                            <SettingsPanel />
                        </div>
                    </div>
                    <div style={{ marginTop: '30px' }}>
                        <h2>Microfrontend-like Widget Area</h2>
                        <p>This widget simulates a microfrontend that reacts to tenant changes via a browser event.</p>
                        <TenantSpecificWidget />
                    </div>
                    <div style={{ marginTop: '30px' }}>
                        <h2>Notification Trigger</h2>
                        <NotificationTrigger />
                    </div>
                </main>
            </div>
        </AuthProvider>
    );
}

export default App;

4. Simulate Microfrontend Shared State (Event Bus)

Let’s create the TenantSpecificWidget component that acts like a microfrontend, listening to the custom event dispatched by our AuthContext.

Create src/components/TenantSpecificWidget.js:

// src/components/TenantSpecificWidget.js
import React, { useEffect, useState } from 'react';

function TenantSpecificWidget() {
    const [currentTenantId, setCurrentTenantId] = useState(null);
    const [lastUpdate, setLastUpdate] = useState('');

    useEffect(() => {
        const handleTenantChange = (event) => {
            console.log('TenantSpecificWidget: Received tenant change event:', event.detail);
            setCurrentTenantId(event.detail.tenantId);
            setLastUpdate(new Date().toLocaleTimeString());
        };

        // Listen for our custom event
        window.addEventListener('app:tenant-changed', handleTenantChange);

        // Clean up the event listener when the component unmounts
        return () => {
            window.removeEventListener('app:tenant-changed', handleTenantChange);
        };
    }, []); // Empty dependency array means this effect runs once on mount

    return (
        <div style={{ border: '2px solid purple', padding: '20px', borderRadius: '10px', background: '#f0e6fa' }}>
            <h3>Tenant-Specific Widget (Simulated Microfrontend)</h3>
            <p>This component is "isolated" and listens for global browser events.</p>
            {currentTenantId ? (
                <>
                    <p>Active Tenant ID: <strong>{currentTenantId}</strong></p>
                    <p>Last Updated: {lastUpdate}</p>
                    <p>Displaying data relevant to tenant {currentTenantId}...</p>
                </>
            ) : (
                <p>No tenant selected. Please log in.</p>
            )}
        </div>
    );
}

export default TenantSpecificWidget;

5. Add a Notification Trigger Component

To demonstrate Zustand’s notificationCount, let’s add a component that can increment it.

Create src/components/NotificationTrigger.js:

// src/components/NotificationTrigger.js
import React from 'react';
import { useSettingsStore } from '../stores/useSettingsStore';

function NotificationTrigger() {
    const incrementNotificationCount = useSettingsStore((state) => state.incrementNotificationCount);

    return (
        <div style={{ border: '1px solid #ccc', padding: '15px', borderRadius: '8px', background: '#f9f9f9', marginTop: '20px' }}>
            <h4>Send Notifications</h4>
            <button onClick={() => incrementNotificationCount(1)}>Send New Notification</button>
            <button onClick={() => incrementNotificationCount(5)} style={{ marginLeft: '10px' }}>Send 5 Notifications</button>
        </div>
    );
}

export default NotificationTrigger;

Now, run your app (npm start) and observe:

  • Logging in/out changes the UserProfile and Header (AuthContext).
  • Toggling the theme in SettingsPanel changes the Header’s background and text color (Zustand).
  • Clicking “Send Notification” updates the count in the Header (Zustand, granular update).
  • Logging in as a different user triggers the custom event, and the TenantSpecificWidget updates its displayed tenant ID without direct prop passing (Event Bus).

This setup elegantly demonstrates how Context API, a modern library like Zustand, and an event bus can coexist to manage different types of state across a large React application, including simulating microfrontend interactions.

Mini-Challenge: User-Specific Theme Setting

Your challenge is to extend the current setup. Instead of a single global theme for all users, make the theme setting user-specific.

Challenge:

  1. When a user logs in, load their preferred theme (e.g., from localStorage or a mock API call).
  2. Store this user-specific theme preference within the AuthContext’s user object, or in a new, user-bound Zustand store.
  3. Modify the SettingsPanel and Header to display and allow changing this user’s theme.
  4. Ensure that when a user logs out, their theme preference is cleared or reset, and when a new user logs in, their theme is loaded.

Hint: Think about how the AuthContext already manages the user object. You could extend this object to include user.themePreference. Alternatively, you could modify useSettingsStore to load/save based on the user.id from AuthContext using an effect. The latter might require a Provider for useSettingsStore if it needs to react to AuthContext changes directly, or you could pass the userId as an argument to actions. Remember to keep state boundaries in mind!

What to observe/learn:

  • How to integrate data from one global state (AuthContext) into another (Zustand settings).
  • The lifecycle of user-specific data and how it interacts with login/logout.
  • The flexibility of Zustand’s create function to handle initialization or updates based on external factors.

Common Pitfalls & Troubleshooting

  1. Context Overuse / Performance Issues:

    • Pitfall: Using React Context for high-frequency updates or very large state objects. This leads to excessive re-renders for all consuming components, even if they only need a small part of the state.
    • Troubleshooting: If you notice performance issues (e.g., slow UI, many re-renders in React Dev Tools) with Context, consider refactoring that specific state into a more optimized library like Zustand or Jotai, or split your Context into smaller, more granular contexts. Remember, Context is best for infrequent updates.
  2. State Colocation vs. Global State:

    • Pitfall: Automatically pushing all state into a global store “just in case.” This makes components less reusable, harder to test, and can lead to a bloated, unmaintainable global state.
    • Troubleshooting: Always default to useState or useReducer for component-local state. Only lift state to a global store when two or more disparate components truly need to share it, or when prop drilling becomes excessive. Ask yourself: “Does this data truly need to be available globally, or just within a specific subtree?”
  3. Microfrontend State Sprawl & Coupling:

    • Pitfall: Implementing multiple, inconsistent ways to share state between microfrontends (e.g., some via events, some via shared Context, some via local storage), or sharing too much state, leading to tight coupling.
    • Troubleshooting: Define a clear, consistent strategy for inter-microfrontend communication. Prioritize highly decoupled methods like custom events or URL parameters. If direct state sharing is necessary via Module Federation, ensure the shared interface is minimal, stable, and well-documented. Regularly audit shared state to ensure it’s still essential and not leading to hidden dependencies.

Summary

Congratulations! You’ve navigated the complex landscape of large-scale state management in React. Here are the key takeaways:

  • Prop drilling is a common problem in growing applications, making code harder to read and maintain.
  • State boundaries are crucial: keep state as local as possible, lifting it only when truly necessary.
  • React Context API is your built-in solution for global state, excellent for infrequent updates like user authentication or themes. Be mindful of its re-rendering behavior.
  • Modern state management libraries like Zustand (version ~4.5.0) offer optimized performance, simpler APIs, and better developer experience for dynamic and frequently updated global state.
  • Jotai (version ~2.7.0) provides an atom-based approach for extremely granular state management and high performance, especially with derived state and async operations.
  • Microfrontend state sharing requires careful architectural decisions to avoid tight coupling. Strategies include shared host state (via Module Federation), event buses, and browser storage (URL, localStorage).
  • The golden rule for microfrontend state: Share as little as possible, and only through stable, well-defined interfaces. Prioritize decoupling.

By applying these principles and tools, you can design React applications with robust, maintainable, and performant state management, ready for the demands of large-scale systems.

In the next chapter, we’ll shift our focus to Cache Hierarchies and Data Fetching Strategies, learning how to efficiently retrieve, store, and invalidate data to further boost your application’s performance and responsiveness.


References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.