Managing Global State in React without Redux

Anton Ioffe - November 21st 2023 - 10 minutes read

As senior-level developers, you've navigated the ebb and flow of JavaScript trends and have likely encountered Redux as a rite of passage for managing global state in React applications. However, if you're ready to pivot towards a more idiomatic approach by harnessing React's own arsenal, this article is your gateway. Walk the untrodden path as we decrypt the depths of Context API with finesse, architect scalable solutions through useReducer and useContext, and craft exquisite custom hooks with the precision of atomic design. Prepare to challenge the status quo of rerenders, delve into the subtleties of memoization—and beyond—and refine your toolkit with state-of-the-art patterns that strike that elusive balance between performance and ease of maintenance. Embrace this journey into the heart of React's native capabilities for global state management, and emerge equipped to write more intuitive, maintainable, and robust applications.

Leveraging Context API: Beyond the Basics

React's Context API provides a powerful and elegant solution for managing global state in components that are scattered across the application. However, developers should be cautious of several nuances when using the context to avoid some of the common pitfalls, such as unnecessary re-renders and complexity.

To refine the use of Context API, it is beneficial to integrate dynamic context values with higher-order components (HOCs). This pattern involves wrapping a component with another component that provides the necessary context. This decouples the context consumer from the provider and allows for more granular control over the data flow and component reusability. For instance, if you want a component to have access to a theme context, you could create a withTheme HOC that simplifies the process of consuming the context and reduces boilerplate:

const ThemeContext = createContext('light');

const withTheme = Component => props => {
  const theme = useContext(ThemeContext);
  return <Component {...props} theme={theme} />;
};

Furthermore, in order to avoid over-rendering, it is important to understand when context consumers will update. A context consumer will re-render whenever the value of the context it references has changed. To prevent unnecessary renders, it is wise to memoize the context value with useMemo or split the context into multiple contexts when dealing with an object that contains multiple fields.

const UserContext = createContext({ name: 'Guest', preferences: {} });

const UserProvider = ({ children }) => {
  const [user, setUser] = useState({ name: 'Guest', preferences: {} });
  const value = useMemo(() => ({ user, setUser }), [user]);

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

Another advanced usage of Context is layering multiple contexts to manage different aspects of the application's state. Separating contexts into logical domains, such as UserContext, ThemeContext, and LanguageContext, enables you to encapsulate different state logic and maintain a clear separation of concerns. This makes the global state easier to manage and understand:

<UserContext.Provider value={userValue}>
  <ThemeContext.Provider value={themeValue}>
    <LanguageContext.Provider value={languageValue}>
      {/* components */}
    </LanguageContext.Provider>
  </ThemeContext.Provider>
</UserContext.Provider>

Lastly, it's important to recognize the issue of "context hell", akin to the infamous "callback hell". To mitigate this, sometimes business logic can be abstracted into custom hooks that internally use context without exposing the context directly to the components that consume the hooks. This encapsulation streamlines the development experience and hides the complexity of the Context API.

function useAuth() {
  const authContext = useContext(AuthContext);
  if (!authContext) {
    throw new Error('useAuth must be used within an AuthProvider');
  }
  return authContext;
}

In conclusion, while Context API provides a straightforward way to pass data through the component tree, leveraging it beyond the basics requires a strategic approach. Through thoughtful context structuring, the use of HOCs, memoization, and custom hooks, you can create a scalable and performance-optimized global state management solution without the overhead of Redux.

Consider the following: Are the contexts in your application fine-grained enough to prevent unwanted re-renders? Are you encapsulating business logic within hooks to promote reusability and modularity? Reflect upon these questions to ensure that your use of the Context API aligns with the advanced patterns and standards for modern web development.

Architecting with UseReducer and useContext for Scalability

Employing useReducer in tandem with useContext renders a powerful combination for state management in React. This pair, in essence, emulates the famed dispatcher-reducer pattern intrinsic to Redux, but without the external dependency. One advantage of this approach is that it naturally encapsulates complex state logic, separating state transition functions (reducers) from component logic. This separation facilitates testing since reducers are pure functions and can be tested in isolation, achieving predictable state transitions.

The adoption of useReducer and useContext affects application performance positively. Unlike passing callback props down the component tree, which can trigger unnecessary re-renders, using context to provide dispatch functions can significantly reduce rendering overhead. However, it's imperative to always return a new state object from the reducer to avoid inadvertent mutations of the current state, which can lead to subtle bugs and inconsistent UIs.

In terms of maintainability, structuring global state with useReducer and useContext increases code readability and encourages a modular design pattern. Unlike the verbose nature of Redux, which is often criticized for its boilerplate code, this native combination can be more concise. Component structures become more understandable as the global state logic is abstractly managed through context providers, and local component states are managed internally with reducers.

Implementing useReducer and useContext also calls for diligence in performance optimization. As state logic becomes more intricate, attention to structuring state and leveraging contexts is paramount. To stay performant, developers must be conscious of how components subscribe to contexts. The goal is to architect the context distribution in a way that updates flow logically and efficiently, akin to how well-designed Redux applications handle state changes selectively through connectors.

A common coding mistake is to manage all state in a singular, global context, leading to all consuming components being re-rendered upon any state change. Best practices suggest keeping the state as localized as possible while employing context to share cross-component state where truly necessary. Such discipline ensures that only components requiring access to specific parts of the state will re-render, enhancing application performance. In contrast to over-segmenting contexts, a balanced and thoughtful use of context with useReducer creates a more maintainable state management system on par with Redux's capabilities without straying into disorganized complexity.

State Management with Custom Hooks: An Atomic Design

Creating custom hooks in React allows developers to encapsulate and manage state more effectively by providing atomic, reusable units of state. An analogy can be drawn between this approach and atomic design principles in UI, where a complex structure starts with small, independent units that can be combined into larger, more complex interfaces. Custom hooks can be designed to represent atomic pieces of state logic, such as a useToggle hook for boolean flags or useCounter for numeric values. By keeping these hooks focused on singular responsibilities, their modularity and reusability are greatly enhanced.

When multiple atomic custom hooks are required to manage a component's state, they can be composed together without bloating individual components with excessive logic. For instance, a useForm hook can internally use multiple useInput hooks for individual form fields. This reusability facilitates a DRY (Don't Repeat Yourself) pattern, where a hook can be shared across components, improving maintainability and readability. However, it is crucial to maintain a balance with global accessibility—hooks that manage state meant to be widely accessible may need to be combined or orchestrated in a different way to ensure broad applicability.

Consider the following real-world code example of a custom hook that leverages the atomic design approach:

function useToggle(initialValue = false){
    const [value, setValue] = useState(initialValue);

    function toggleValue(){
        setValue(currentValue => !currentValue);
    }

    return [value, toggleValue];
}

This hook manages an individual piece of state and allows any component to integrate a toggle functionality with ease. But how do we take this further to manage more complex, interconnected pieces of global state? By defining hooks that use other hooks or internally manage their interdependencies, we can create a cohesive state management ecosystem that operates on the principles of atomic design.

A common mistake in creating custom hooks for state management is the overexposure of state. This can lead to fragile components that are hard to refactor, test, and reason about. Being disciplined about the scope of a hook's state is key to avoiding such pitfalls:

// Avoid: Exposing unnecessary internal state
function useForm(){
    // Too much detail may lead to misuse and tight coupling
    const [name, setName] = useInput('Jane Doe');
    const [email, setEmail] = useInput('jane.doe@example.com');
    // ...other fields

    return {
        name,
        setName,
        email,
        setEmail
        // ...other fields
    };
}

// Prefer: Encapsulating hook complexity and providing a simplified interface
function useForm(){
    const name = useInput('Jane Doe');
    const email = useInput('jane.doe@example.com');
    // ...other fields

    // Provide a simpler API for the rest of the application
    function getFormData(){
        return {
            name: name.value, // Assuming useInput returns an object
            email: email.value // with a 'value' property
            // ...other fields
        };
    }

    // ... other related form logic

    return { getFormData };
}

As developers consider incorporating custom hooks in their React projects, they must weigh the benefits of modularity and reusability against the needs for more globalized state management. A thought-provoking question to ask is: At what point does the introduction of more hooks to manage different aspects of state become counterproductive, and how do we recognize and mitigate this complexity?

Propagation of State Changes: Strategies to Optimize Rerenders

To mitigate unnecessary re-renders when managing global state, it's imperative to understand and utilize memoization effectively. Components should only re-render when the state they depend on changes, not on every global state update. Implementing React.memo for functional components or shouldComponentUpdate for class components allows you to specify precisely when re-rendering should occur. For instance:

const MyComponent = React.memo(function MyComponent(props) {
    /* render using props */
});

This ensures that MyComponent only re-renders when the relevant props have actually changed, rather than every time the parent component re-renders.

Beyond basic memoization, strategies such as batching updates are substantial. In scenarios where multiple state variables update simultaneously, grouping these updates into a single update cycle can prevent multiple re-renders. React's state update functions can sometimes batch updates automatically, such as within event handlers or lifecycle methods. For updates that don’t benefit from React's automatic batching, structuring your state updates in a way that takes advantage of React's batching can help:

function handleMultipleStateChanges() {
    // React may batch these state updates automatically
    updateStateOne();
    updateStateTwo();
}

With the advent of hooks, useCallback and useMemo come into play to further optimize render behavior, especially in components with complex interdependencies. useCallback is used to memoize functions to prevent unnecessary recreation on re-renders:

const memoizedCallback = useCallback(
    () => {
        doSomethingWithValues(a, b);
    },
    [a, b],
);

This is beneficial when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders. Meanwhile, useMemo aids in memoizing expensive calculations:

const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);

It's important to note that useMemo and useCallback should not be used indiscriminately, as memoization itself has a cost. They're most effective when dealing with expensive operations or deep object comparisons.

Careful consideration of when and where to update state is the key to minimizing re-renders. In components with deep state interdependencies, the selected strategy may impact performance. For example, lifting global state updates to a common ancestor might seem beneficial, but it can lead to excessive re-renders of intermediate components. A critical aspect is conducting performance profiling to identify bottlenecks caused by unnecessary re-renders. This might reveal that certain updates do not warrant the memoization overhead and can be simplified by restructuring component hierarchies or rethinking state logic.

Refining Global State Access: Advanced Patterns and Best Practices

In modern web development, managing global state efficiently is paramount for creating scalable and responsive applications. Advanced patterns such as the usage of selective context consumption allow components to subscribe only to relevant slices of state through custom-built logic or helper functions, thus averting needless re-renders. By adopting these fine-grained subscription patterns, developers ensure components re-render only when necessary, maintaining an application that is performant and predictable.

State machines introduce an organized approach to state transitions and associated side effects, making code self-documenting and easier to manage. Consider a state machine managing a login flow:

import { useReducer } from 'react';

// Define the states and transitions of the login state machine
const loginStateMachine = {
    initialState: 'loggedOut',
    states: {
        loggedOut: {
            on: { LOGIN: 'loggingIn' }
        },
        loggingIn: {
            on: { SUCCESS: 'loggedIn', FAILURE: 'errorLoggingIn' }
        },
        loggedIn: {
            on: { LOGOUT: 'loggedOut' }
        },
        errorLoggingIn: {
            on: { RETRY: 'loggingIn', LOGOUT: 'loggedOut' }
        }
    }
};

// Reducer to handle state transitions based on events
function loginReducer(state, event) {
    return loginStateMachine.states[state]?.on[event] || state;
}

// LoginComponent utilizing the useReducer hook with the login state machine
function LoginComponent() {
    const [state, dispatch] = useReducer(loginReducer, loginStateMachine.initialState);
    // Component logic and UI here
}

This example of a loginReducer demonstrates a predictable, state-driven approach to a login flow, encapsulating logic within the state machine.

These advanced state management patterns, while potentially complex, help eliminate unexpected side effects by enforcing strict state transitions, leading to a bug-resistant codebase. Developers need to maintain a careful balance, ensuring that these tools are employed only when they provide clear benefits without overcomplicating the application.

For more refined state access, custom hooks can encapsulate complex logic. Here's an example of a hook providing access to a specific part of the context state:

import { useContext } from 'react';
import { DataContext } from './contexts';

// Custom hook to select and return part of the global state
function useDataSelector(selector) {
    const context = useContext(DataContext);
    // Apply the provided selector to the context value to obtain needed data
    const selectedData = selector(context);
    return selectedData;
}

The useDataSelector hook simplifies state consumption, masking the complexity of global state management within a more manageable and concise interface.

As advanced techniques are integrated, developers should critically assess whether these patterns serve the application efficaciously or merely introduce superfluous complexity. The question stands: How do we discern the precise moment when state management abstractions become a hindrance rather than a help to our development process? It is incumbent upon developers to strike this balance, continuously adapting state management to serve the application's evolving requirements and ensuring the chosen approach aligns with imminent scalability needs.

Summary

In this article, senior-level developers are introduced to an alternative to Redux for managing global state in React applications. The article covers the use of React's Context API, including advanced techniques such as integrating dynamic context values with higher-order components, memoizing context values, and layering multiple contexts. It also explores the combination of useReducer and useContext for scalable state management and the use of custom hooks to encapsulate and manage state effectively. The article emphasizes the importance of optimizing re-renders and provides strategies for memoization and performance optimization. The key takeaway is that with careful consideration and adoption of these advanced patterns, developers can create scalable, maintainable, and performant applications without the need for Redux. The challenging technical task for the reader is to reflect on their own application and consider if their use of the Context API aligns with advanced patterns and standards for modern web development. They are encouraged to evaluate if their contexts are fine-grained enough to prevent unwanted re-renders and if they are encapsulating business logic within hooks to promote reusability and modularity.

Don't Get Left Behind:
The Top 5 Career-Ending Mistakes Software Developers Make
FREE Cheat Sheet for Software Developers