React Hooks: useContext

React Hooks: useContext

Hello again👋

React hooks have revolutionized how developers manage state and side effects in functional components. If you’re already familiar with useState for handling local component state, you’re off to a great start (If not, please check this out). As your application grows, however, you'll likely encounter situations where passing state through multiple levels of components (the dreaded term "prop drilling") becomes cumbersome and hard to manage. This is where useContext shines.

While useState is perfect for managing state within a single component, useContext provides a way to share values across the entire component tree without the need for manual prop passing at each level. Think of useState as handling local concerns, and useContext as managing global concerns.

In this article, we'll explore how useContext can simplify your state management in React applications by:

  • Creating a context to hold your global state.

  • Providing this context to your component tree.

  • Consuming the context in any component, anywhere in your tree.

By the end, you'll see how useContext complements useState to provide a more scalable solution for state management, making your code cleaner and more maintainable. Let’s dive in!

useContext and Prop drilling

useContext is a hook in React that allows you to subscribe to a context within a functional component. Context provides a way to pass data through the component tree without having to pass props down manually at every level.
Usually, you can pass data from a parent component to a child component as a prop. In the example below, we can access and display the name state variable in Child as the input value changes in Parent.

import { useState } from "react";

const Parent = () => {
const [name, setName] = useState("");
    return (
        <div>
            <input 
            type="text" 
            value={name} 
            placeholder="Enter name" 
            onChange={e => setName(e.target.value)} 
            />
            <Child name={name} />
        </div>
    )
}

const Child = ({name}) => {
    return (
        <div>
            <p>{name}<p/>
        </div>
    )
}

A textual representation of the UI tree will look like this:

[Parent] (props: name)
  |
  +--[Child] (props: name)
  |    |
      (...)

However, what if we had to pass this state through multiple layers of our app to be used? As our app grows, and consequently the source code, complexity increases and more components are created. Components not immediately connected to a prop-providing component may need to access said prop. For instance, we could initially have a simple Profile component where the user can update their name and email. We start by managing the state within this component using useState

import React, { useState } from 'react';

const Profile = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleNameChange = (e) => setName(e.target.value);
  const handleEmailChange = (e) => setEmail(e.target.value);

  return (
    <div>
      <h1>Profile</h1>
      <input type="text" value={name} onChange={handleNameChange} placeholder="Name" />
      <input type="email" value={email} onChange={handleEmailChange} placeholder="Email" />
      <p>Name: {name}</p>
      <p>Email: {email}</p>
    </div>
  );
};

export default Profile;

Now, let's say we want to add more components to our application. For instance, a UserDashboard component that displays user information and an AccountSettings component where the user can update their profile. We need to pass the name and email state down to these components. These components may also have child components that require these props or may act as carriers for these props to the next child component, and on and on it could go. This results in prop drilling, making state management more stressful and harder to maintain.

import React, { useState } from 'react';
import UserDashboard from './UserDashboard';
import AccountSettings from './AccountSettings';

const App = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  const handleNameChange = (e) => setName(e.target.value);
  const handleEmailChange = (e) => setEmail(e.target.value);

  return (
    <div>
      <h1>App</h1>
      <UserDashboard name={name} email={email} />
      <AccountSettings name={name} email={email} onNameChange={handleNameChange} onEmailChange={handleEmailChange} />
    </div>
  );
};

export default App;
[App] (props: name, email, onNameChange, onEmailChange)
  |
  +--[UserDashboard] (props: name, email)
  |    - Displays user information
  |
  +--[AccountSettings] (props: name, email, onNameChange, onEmailChange)
       - Allows user to update profile

This is when useContext comes into play. In:

  • Global State Management: When you have data that needs to be accessed by many components at different levels of the component tree, such as user authentication status, theme settings, or language preferences.

  • Avoiding Prop Drilling: When you want to avoid passing props down through multiple layers of components.

How to use useContext

  1. Create a context:

     // MyContext.js
     import React, { createContext } from 'react';
    
     // Create a context with a default value (optional)
     export const MyContext = createContext(defaultValue);
    
  2. Provide a Context: Wrap your component tree with the context provider and pass the context value.

     import React from 'react';
     import ReactDOM from 'react-dom';
     import App from './App';
     import { MyContext } from './MyContext';
    
     const contextValue = {
       // your context value here
     };
    
     ReactDOM.render(
       <MyContext.Provider value={contextValue}>
         <App />
       </MyContext.Provider>,
       document.getElementById('root')
     );
    
  3. Consume the Context: Use the useContext hook in any functional component to access the context value.

     import React, { useContext } from 'react';
     import { MyContext } from './MyContext';
    
     const MyComponent = () => {
       const contextValue = useContext(MyContext);
    
       return (
         <div>
           {/* Use the context value */}
           <p>{contextValue.someProperty}</p>
         </div>
       );
     };
    

We can simplify state management in our prop drilling example from above using useContext. This way we can manage the user's profile information globally, eliminating the need for prop drilling.

import React, { createContext, useContext, useState } from 'react';

// Create a context for the user profile
const UserProfileContext = createContext();

const App = () => {
  const [name, setName] = useState('');
  const [email, setEmail] = useState('');

  return (
    <UserProfileContext.Provider value={{ name, email, setName, setEmail }}>
      <h1>App</h1>
      <UserDashboard />
      <AccountSettings />
    </UserProfileContext.Provider>
  );
};

const UserDashboard = () => {
  const { name, email } = useContext(UserProfileContext);

  return (
    <div>
      <h2>User Dashboard</h2>
      <p>Name: {name}</p>
      <p>Email: {email}</p>
      {/* Child nodes that may require name / email and/or their setter functions */}
    </div>
  );
};

const AccountSettings = () => {
  const { name, email, setName, setEmail } = useContext(UserProfileContext);

  return (
    <div>
      <h2>Account Settings</h2>
      <input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="Name" />
      <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" />
      {/* Child nodes that may require name / email and/or their setter functions */}
    </div>
  );
};

export default App;

By using useContext, we have simplified our application’s state management and eliminated the stress of prop drilling, making our components more modular and easier to manage.
You can access and utilize multiple contexts from anywhere in your app by stacking them. Just remember to wrap your component tree with their providers and provision the values!

In an application, you can have multiple contexts in separate files stored in a folder in your application. This provides modularity and readability.

src/
└── contexts/
    ├── AuthContext.js
    ├── ThemeContext.js
    ├── UserContext.js
    └── ...

You can have an auth context that manages your auth state globally so any component node within the wrapped UI tree can verify if a user is signed in or not. This could help you decide what parts of your UI should be displayed, log out an unauthenticated or unauthorized user, etc.

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

// Create a context for the user authentication
const AuthContext = createContext();

export const AuthProvider = ({children}) => {
    const [isLoggedIn, setIsLoggedIn] = useState(false);

    const login = () => setIsLoggedIn(true);
    const logout = () => setIsLoggedIn(false);  

    return (
        <AuthContext.Provider value={{ isLoggedIn, login, logout }}>
            {children}
        </AuthContext.Provider>
    )
}

export const useAuthContext = () => {
    return useContext(AuthContext);
}

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

const ThemeContext = createContext();

export const ThemeProvider = ({ children }) => {
  const [theme, setTheme] = useState('light');

  const toggleTheme = () => {
    setTheme(theme === 'light' ? 'dark' : 'light');
  };

  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
};

export const useThemeContext = () => {
  return useContext(ThemeContext);
};

export default ThemeContext;

We can also create a UserContext. This context can be used in our UserDashboard and AccountSettings components to update and display user information.

// src/contexts/UserContext.js
import { createContext, useContext, useState } from 'react';

// Create a context for the user profile
const UserContext = createContext();

export const UserProvider = ({children}) => {
    const [user, setUser] = useState({
        name: 'John Doe',
        email: 'john.doe@example.com',
        age: 30,
        city: 'New York',
    });

   const updateUser = (updatedUser) => {
        setUser((prevUser) => ({
          ...prevUser,
          ...updatedUser,
        }));
   };

   return (
       <UserContext.Provider value={{ user, updateUser }}>
           {children}
       </UserContext.Provider>
   )
}

export const useUserContext = () => {
    return useContext(UserContext);
}

export default UserContext;

After creating these contexts, we can subscribe our component tree to them and provide the values by wrapping it in the providers we have already created.

// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { UserProvider } from '@/contexts/UserContext';
import { AuthProvider } from '@/contexts/AuthContext';
import { ThemeProvider } from '@/contexts/ThemeContext';

// ...
ReactDOM.render(
  <UserProvider>
    <AuthProvider>
        <ThemeProvider>
            <App />
        </ThemeProvider>
    </AuthProvider>
  </UserProvider>,
  document.getElementById('root')
);
// ...

By using useContext, we have simplified our application’s state management using cleaner code and eliminated the stress of prop drilling, making our components more modular and easier to manage and update from any component within the provider's scope, while also reducing boilerplate and potential errors.

Considerations

  • Performance: Frequent updates to the context value can cause performance issues, as every component that consumes the context will re-render.
    There are several strategies to combat this:

    1. Split contexts: A good practice is to separate unrelated contexts, like we did above, to prevent triggering of unnecessary re-renders in components that only depend on other parts of the state.

    2. Memoization: Memoization can help optimize components that consume context values by preventing unnecessary re-renders. You can use React.memo for functional components or PureComponent for class components to memoize components and ensure they only re-render when their props or state change.

    3. Use Dispatch Function: Instead of directly updating the context value from multiple components, use a dispatch function pattern (similar to Redux) to update the context. This can reduce the frequency of context updates and improve performance by batching updates or implementing optimizations like debouncing.

    4. Optimize Consumer Components: Ensure that components consuming context values are optimized for performance. Avoid unnecessary computations in render methods, use efficient data fetching techniques, and implement pagination or lazy loading where applicable.

    5. UseuseMemooruseCallback: In components consuming context values, use useMemo to memoize expensive computations derived from context values, and useCallback to memoize event handlers or functions passed down as props.

  • Separation of Concerns: Overusing context for state management can lead to a less modular and harder-to-maintain codebase. As much as is possible, when building software systems, we should strive towards "High cohesion, low coupling, and strong modularity". To address this, it's important to follow best practices:

    1. Single Responsibility Principle: Each context should ideally manage a single concern or domain of state. For example, separate contexts for authentication, theme, user profile, etc., rather than combining all state into one global context.

    2. Component-Level State: Use local component state (useState) for state that is specific to a particular component and doesn’t need to be shared globally via context.

    3. Context Composition: Compose contexts hierarchically, where each context encapsulates a specific area of functionality or state management, promoting a more modular architecture.

    4. Avoid Over-Nesting: Avoid nesting contexts excessively. Consider if a component really needs access to a particular context before providing it via context provider.

    5. State Management Libraries: For complex state management needs, consider using state management libraries like Redux or Recoil, which provide more structured ways to manage and update state across components without overusing context.

Conclusion

To wrap up, useContext is a game-changer for managing state in React, making it easy to share values across your app without the hassle of prop drilling. By setting up and using contexts, you can streamline your state management, keeping your code cleaner and easier to maintain. Just remember to use it wisely to avoid performance hiccups and keep your code modular. When done right, useContext can really boost your React app's scalability and manageability.

Happy Coding!🙌