Skip to Content
Lesson 6

Advanced Context Patterns

Workplace Context

As React applications scale, managing global or widely shared state effectively becomes even more critical. While a single context can solve basic prop drilling, complex applications often require more sophisticated approaches. Developers frequently encounter scenarios needing multiple, independent contexts, or ways to compose context providers and consumers elegantly. Understanding advanced context patterns, including optimization techniques and TypeScript integration, is essential for building robust, maintainable, and type-safe large-scale React applications.

Learning Objectives

By the end of this lesson, learners will be able to:

  • Implement and manage multiple, independent contexts within an application.
  • Compose context providers and consumers effectively.
  • Utilize Context API with TypeScript for improved type safety.
  • Apply advanced optimization patterns for contexts to prevent unnecessary re-renders.
  • Recognize and implement common context patterns for various use cases.

Using Multiple Contexts

Applications often have different types of global state that are unrelated. For example, theme information (light/dark mode) is distinct from user authentication status. Instead of putting all unrelated global state into a single, massive context, it’s often better to use multiple, more focused contexts.

Benefits of Multiple Contexts:

  • Separation of Concerns: Each context manages a specific piece of global state, making the codebase cleaner and easier to understand.
  • Reduced Re-renders: Components consuming a specific context will only re-render when that particular context’s value changes. If all global state were in one context, a change to any piece of it would cause all consumers to re-render, potentially harming performance.
  • Better Granularity: Components can subscribe only to the data they actually need.

Implementation:

You simply create and provide multiple contexts as needed. A component can consume as many different contexts as necessary using multiple useContext calls.

contexts.js
import React from 'react'; export const ThemeContext = React.createContext({ theme: 'light', toggleTheme: () => {} }); export const UserContext = React.createContext({ currentUser: null, login: () => {}, logout: () => {} });
App.js
import React, { useState, useMemo } from 'react'; import { ThemeContext, UserContext } from './contexts'; import PageLayout from './PageLayout'; // Consumes both contexts function App() { // Theme state and logic const [theme, setTheme] = useState('light'); const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light')); const themeValue = useMemo(() => ({ theme, toggleTheme }), [theme]); // User state and logic const [currentUser, setCurrentUser] = useState(null); const login = (user) => setCurrentUser(user); const logout = () => setCurrentUser(null); const userValue = useMemo(() => ({ currentUser, login, logout }), [currentUser]); return ( <ThemeContext.Provider value={themeValue}> <UserContext.Provider value={userValue}> <PageLayout /> </UserContext.Provider> </ThemeContext.Provider> ); }
PageLayout.js
import React, { useContext } from 'react'; import { ThemeContext, UserContext } from './contexts'; function PageLayout() { const { theme } = useContext(ThemeContext); const { currentUser } = useContext(UserContext); return ( <div style={{ background: theme === 'dark' ? '#222' : '#EEE', color: theme === 'dark' ? 'white' : 'black' }}> {currentUser ? <p>Welcome, {currentUser.name}!</p> : <p>Please log in.</p>} {/* ... other layout components ... */} </div> ); }

This pattern allows PageLayout to react to theme changes and user status changes independently.


Context Composition

While nesting providers as shown above is common, sometimes you might want to create more sophisticated compositions, especially if provider setup becomes complex or if you want to abstract away the context details further.

Custom Provider Components

One way to manage multiple providers or complex provider logic is to create a custom provider component that encapsulates the setup for one or more contexts.

AppProviders.js
import React, { useState, useMemo } from 'react'; import { ThemeContext, UserContext } from './contexts'; // Assuming contexts are defined elsewhere export function AppProviders({ children }) { // Theme state and logic const [theme, setTheme] = useState('light'); const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light')); const themeValue = useMemo(() => ({ theme, toggleTheme }), [theme]); // User state and logic const [currentUser, setCurrentUser] = useState(null); const login = (user) => setCurrentUser(user); const logout = () => setCurrentUser(null); const userValue = useMemo(() => ({ currentUser, login, logout }), [currentUser]); return ( <ThemeContext.Provider value={themeValue}> <UserContext.Provider value={userValue}> {children} </UserContext.Provider> </ThemeContext.Provider> ); }
index.js
// ... import { AppProviders } from './AppProviders'; ReactDOM.render( <React.StrictMode> <AppProviders> <App /> </AppProviders> </React.StrictMode>, document.getElementById('root') );

This AppProviders component cleans up your main application file (App.js or index.js) by centralizing context provider logic.

Composing Custom Hooks with Context

You can also create custom Hooks that consume one or more contexts and provide a more tailored API to components.

useAppServices.js
import { useContext } from 'react'; import { ThemeContext, UserContext, NotificationContext } from './contexts'; // Assume NotificationContext also exists export function useAppServices() { const themeManager = useContext(ThemeContext); const userAuth = useContext(UserContext); const notificationService = useContext(NotificationContext); // You can return them as is, or combine/transform them return { theme: themeManager.theme, toggleTheme: themeManager.toggleTheme, currentUser: userAuth.currentUser, loginUser: userAuth.login, logoutUser: userAuth.logout, showNotification: notificationService.showNotification // Example }; }
MyComponent.js
import { useAppServices } from './useAppServices'; function MyComponent() { const { theme, currentUser, showNotification } = useAppServices(); // ... use these values ... }

This useAppServices Hook abstracts away the individual useContext calls and can provide a more domain-specific API.


Context with TypeScript

Using Context with TypeScript provides better type safety and developer experience. Key aspects include typing the context value, the createContext default value, and the props for custom provider components.

Define Types for Context Value

types.ts
export interface ThemeContextType { theme: 'light' | 'dark'; toggleTheme: () => void; } export interface User { id: string; name: string; } export interface UserContextType { currentUser: User | null; login: (user: User) => void; logout: () => void; }

Create Typed Context

When calling React.createContext, you can provide a type argument. If the default value doesn’t perfectly match the type (e.g., providing null or a partial object when functions are expected), you might need to use a type assertion or provide a more complete default that satisfies the type (often a stubbed-out version).

contexts.ts
import React from 'react'; import { ThemeContextType, UserContextType, User } from './types'; // Providing a default that matches the type, even if it's just stubs export const ThemeContext = React.createContext<ThemeContextType>({ theme: 'light', toggleTheme: () => console.warn('no theme provider'), }); export const UserContext = React.createContext<UserContextType>({ currentUser: null, login: (user: User) => console.warn('no user provider'), logout: () => console.warn('no user provider'), });

Alternatively, if a sensible default is hard to provide or if you always expect a Provider, you can pass undefined and assert the type, or check for undefined in consumers (though this is less common).

Typed Provider Component

AppProviders.tsx
import React, { useState, useMemo, ReactNode } from 'react'; import { ThemeContext, UserContext } from './contexts'; import { ThemeContextType, UserContextType, User } from './types'; interface AppProvidersProps { children: ReactNode; } export function AppProviders({ children }: AppProvidersProps) { const [theme, setTheme] = useState<ThemeContextType['theme']('light'); const toggleTheme = () => setTheme(t => (t === 'light' ? 'dark' : 'light')); const themeValue: ThemeContextType = useMemo(() => ({ theme, toggleTheme }), [theme]); const [currentUser, setCurrentUser] = useState<User | null>(null); const login = (user: User) => setCurrentUser(user); const logout = () => setCurrentUser(null); const userValue: UserContextType = useMemo(() => ({ currentUser, login, logout }), [currentUser]); return ( <ThemeContext.Provider value={themeValue}> <UserContext.Provider value={userValue}> {children} </UserContext.Provider> </ThemeContext.Provider> ); }

Typed Consumption with useContext

TypeScript will infer the type of the value returned by useContext based on the type provided to React.createContext.

MyComponent.tsx
import React, { useContext } from 'react'; import { ThemeContext, UserContext } from './contexts'; import { ThemeContextType, UserContextType } from './types'; // Types might not be needed here if context is well-typed function MyComponent() { const { theme, toggleTheme } = useContext(ThemeContext); const { currentUser, login } = useContext(UserContext); // theme is correctly typed as 'light' | 'dark' // currentUser is correctly typed as User | null return ( <div onClick={toggleTheme} style={{ color: theme === 'dark' ? 'white' : 'black'}}> {currentUser ? `Hello, ${currentUser.name}` : "Please login"} </div> ); }

Advanced Context Optimization

Lesson 5 covered basic performance considerations like memoizing the value prop and splitting contexts. Here are a few more advanced points for future consideration and reference:

  1. Selector Pattern (Manual): If a context provides a large object but a component only needs a small piece of it, the component will still re-render if any part of the large object changes. To mitigate this, you can create a custom Hook that consumes the context but only returns the specific slice of data needed. This doesn’t stop the Hook itself from re-running, but if the sliced data reference hasn’t changed, React.memo on the consuming component can prevent its re-render.

    A more robust way (though more complex to implement manually) is for the custom Hook to use useState internally for the sliced value and useEffect to update this state only when the specific part of the context value it cares about changes. Libraries like use-context-selector formalize this.

  2. useContextSelector (Third-party Library): Libraries like use-context-selector provide a Hook that allows components to subscribe to only specific parts of a context value. The component will only re-render if the selected part changes.

    import { useContextSelector } from 'use-context-selector'; const specificValue = useContextSelector(MyContext, contextData => contextData.specificField);

    This can be very effective, but it adds a dependency and a slightly different API.

  3. Referential Stability of Context Functions: If your context value includes functions (e.g., updateUser, toggleTheme), ensure these functions have stable references using useCallback in the Provider component where they are defined. Otherwise, new function references on each render will cause all consumers to re-render, even if the state those functions operate on hasn’t changed.

    // In Provider component const [user, setUser] = useState(null); const login = useCallback((userData) => { setUser(userData); }, []); // Empty dependency array if setUser itself is stable const contextValue = useMemo(() => ({ user, login }), [user, login]);

Optimization is a balancing act. Always profile your application to identify actual bottlenecks before applying complex optimization patterns.


Common Context Patterns

  • Feature Flag Context: Provide feature flag configurations to enable/disable UI elements or behaviors throughout the app.
  • Router Context: Libraries like React Router use Context internally to provide routing information (current location, history object) to components like <Link> and <Route>.
  • Internationalization (i18n) Context: Provide current language, translation functions, and date/number formatting preferences.
  • Modal/Notification System Context: A context to manage the state and rendering of global modals, toasts, or notifications (e.g., showModal(content), hideModal()).
  • Dependency Injection for Services: Context can be used as a lightweight form of dependency injection, providing service instances (e.g., an API service client) to components deep in the tree.

Activity 1: Build a Multi-Context Application

Task: Extend the Theme and User context examples from Lesson 5 (or create new ones) and combine them in an application. Ensure that a component can consume both contexts and that changing one context value (e.g., theme) does not unnecessarily cause components that only consume the other context (e.g., user) to re-render if implemented correctly.

Requirements:

  1. Define ThemeContext (theme, toggleTheme) and UserContext (currentUser, login, logout).
  2. Create a top-level provider component (e.g., AppProviders) that provides both contexts.
  3. Create a Header component that consumes UserContext to display user info/login button.
  4. Create a Sidebar component that consumes ThemeContext to style itself.
  5. Create a MainContent component that consumes both ThemeContext (for styling) and UserContext (to display user-specific content).
  6. Demonstrate that updating the theme does not cause the Header (if it only uses UserContext) to re-render unnecessarily (this might involve using React.memo and console logging in component renders for verification).

Activity 2: Implement Context with Proper Typing (TypeScript)

Task: Take one of the previous context examples (Theme or User) and refactor it to use TypeScript for full type safety.

Requirements:

  1. Define interfaces or types for the context value (e.g., ThemeContextState, UserContextState).
  2. Create the context using React.createContext<MyContextType>(defaultValue) with appropriate typing for the default value.
  3. Type the props for your custom Provider component(s), including children.
  4. Ensure that useContext correctly infers the types in consuming components, providing autocompletion and type checking.

Knowledge Check

What is a primary advantage of using multiple, focused contexts (e.g., ThemeContext, UserContext) over a single large context for all global state?

  • Select an answer to view feedback.

When creating a custom provider component (e.g., AppProviders) that encapsulates multiple context providers, what is the main benefit?

  • Select an answer to view feedback.

When using Context with TypeScript, what is a common practice for defining the shape of the context value?

  • Select an answer to view feedback.

If a context value contains functions (e.g., toggleTheme, loginUser), why is it important to ensure these functions have stable references (e.g., using useCallback) in the Provider?

  • Select an answer to view feedback.

Summary

Advanced context patterns enable more scalable and maintainable state management in React. Using multiple, focused contexts helps in separating concerns and optimizing performance. Composing providers and creating custom Hooks that consume context can lead to cleaner component APIs. TypeScript enhances Context usage by providing type safety and better developer experience. While Context is powerful, always consider its performance implications and choose the right patterns for your application’s needs, balancing convenience with efficiency.


References

Additional Resources