paint-brush
Optimizing State Management in React Applications: From Small to Large-Scaleby@ljaviertovar
1,407 reads
1,407 reads

Optimizing State Management in React Applications: From Small to Large-Scale

by L Javier TovarOctober 29th, 2023
Read on Terminal Reader
Read this story w/o Javascript
tldt arrow

Too Long; Didn't Read

Managing state is crucial in large-scale React applications. This blog explores various state management techniques, including local state components, component composition to avoid prop drilling, React Context, state management libraries like Redux, and tools such as React Query and Apollo Client for GraphQL-based apps. The choice of state management depends on the application's complexity, and a combination of these approaches can be employed for optimal performance and code maintainability.
featured image - Optimizing State Management in React Applications: From Small to Large-Scale
L Javier Tovar HackerNoon profile picture


As a front-end developer, state management in React applications is a critical part of ensuring smooth and maintainable code. As our applications grow in complexity and size, handling state becomes crucial for maintaining performance, organization, and code reusability.


In React, a stateful component is one that can store and modify data throughout the application’s lifecycle. Stateful components are useful when we need a component to maintain and update specific information and communicate with other components.


In this blog, we will explore some techniques for effectively handling state in React.


Local State Components

When building smaller components with limited state requirements, using local state is a simple and easy-to-implement approach. This keeps the component’s logic self-contained and avoids adding unnecessary complexity.


However, we must be careful not to abuse the local state, as it can lead to prop drilling issues and difficulty in managing the state throughout the application.


To use stateful components, we utilize the useState hook, which is a feature introduced in React 16.8.


Here’s an example of how to implement a local stateful component in a React application:


import React, { useState } from 'react';

const Counter = () => {
  // We set the initial state of the counter to 0
  const [count, setCount] = useState(0);

  // Function to increment the counter by 1
  const increment = () => {
    setCount(count + 1);
  };

  // Function to decrement the counter by 1
  const decrement = () => {
    setCount(count - 1);
  };

  return (
    <div>
      <h2>Contador: {count}</h2>
      <button onClick={increment}>Incrementar</button>
      <button onClick={decrement}>Decrementar</button>
    </div>
  );
};

export default Counter;


Prop Drilling and Component Composition

When working on slightly more extensive and more complex applications, it’s common to encounter situations where a component needs to access a state that resides at a higher level in the component tree.


This process of passing props through multiple levels of components is known as prop drilling, and it can become cumbersome and error-prone as the application grows.


One solution to avoid prop drilling is to use component composition. Instead of passing props directly from the top-level component down to the deepest component, we create higher-order components that wrap around the components needing access to the state.


These higher-order components take care of providing the necessary state and functions to the descendant components, thus avoiding prop drilling.


Let’s look at the same example of the counter to better understand this concept:


// Highest level component that stores the state
const App = () => {
  const [count, setCount] = useState(0);

  const incrementCount = () => {
    setCount((prevCount) => prevCount + 1);
  };

  return (
    <div>
      <h1>Counter: {count}</h1>
      {/* Higher-order component providing status and update function */}
      <Counter incrementCount={incrementCount} count={count} />
    </div>
  );
};

// Component that needs access to the state
const Counter = ({ incrementCount, count }) => {
  return (
    <div>
      <button onClick={incrementCount}>Increment</button>
      {/* Deeper component using the update function */}
      <CounterDisplay count={count} />
    </div>
  );
};

// Deeper component showing the current status
const CounterDisplay = ({ count }) => {
  return <p>Current counter: {count}</p>;
};


In the previous example, we used three components: AppCounter, and CounterDisplay. The counter state is stored in the App component, and both the Counter and CounterDisplay components need access to this state.


Instead of directly passing the state and update function down through props to the CounterDisplay component, we employ component composition and create a higher-order component called Counter. This component takes care of providing the state and update function to the CounterDisplay component.


React Context and the useContext Hook

React Context is a powerful tool for managing states across multiple components without resorting to prop drilling. It offers a way to share data throughout the component tree without the need to pass props explicitly.


Context is especially useful when multiple components need access to the same global state or data.


To use React Context, we first create a context object using createContext() and define an initial value. Then, we can wrap the components that need access to this context with the Context.


Provider component, passing the context value as a prop. Descendant components can access the context value using the useContext hook.


Let’s see the same example of the counter to better understand this concept:


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

// We create the context object for the counter
const CounterContext = createContext();

// Component providing the context with the counter reading
const CounterProvider = ({ children }) => {
  const [count, setCount] = useState(0);

  const increment = () => {
    setCount(prevCount => prevCount + 1);
  };

  const decrement = () => {
    setCount(prevCount => prevCount - 1);
  };

  return (
    <CounterContext.Provider value={{ count, increment, decrement }}>
      {children}
    </CounterContext.Provider>
  );
};

// Component that consumes the context using useContext
const CounterDisplay = () => {
  const { count } = useContext(CounterContext);

  return (
    <div>
      <h2>counter: {count}</h2>
    </div>
  );
};

// Component that consumes the context using useContext
const CounterButtons = () => {
  const { increment, decrement } = useContext(CounterContext);

  return (
    <div>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
    </div>
  );
};

// Main component where CounterProvider, CounterDisplay and CounterButtons are used.
const App = () => {
  return (
    <CounterProvider>
      <CounterDisplay />
      <CounterButtons />
    </CounterProvider>
  );
};

export default App;


In this example, we create a CounterContext object and a CounterProvider component that provides the counter value to the context along with the incrementand decrementfunctions to modify the counter.


Then, the CounterDisplay and CounterButtons components consume the context using the useContextHook to access the counter value and functions to modify it.


State Management Libraries

In large-scale applications, where state complexity is higher, using state management libraries like Redux, Mobx, Zustand, etc., becomes a valuable option.


These libraries offer predictable and structured state management, making it easier to handle shared data throughout the application.


Let’s see a simple example of using Redux.


  1. Install Redux and React-Redux using npm or yarn.


npm install redux react-redux


2. Create the file store.js where we will define our Redux store.


// store.js
import { createStore } from 'redux';

// Define the initial state of the application
const initialState = {
  count: 0,
};

// Define the reducer function that will handle state updates based on actions
const reducer = (state = initialState, action) => {
  switch (action.type) {
    case 'INCREMENT':
      return {
        ...state,
        count: state.count + 1,
      };
    case 'DECREMENT':
      return {
        ...state,
        count: state.count - 1,
      };
    default:
      // If the action type is not recognized, return the current state
      return state;
  }
};

// Create the Redux store using the reducer
const store = createStore(reducer);

export default store;


3. Create a component Counter that will use the state stored in the Redux store.


import React from 'react';
import { useDispatch, useSelector } from 'react-redux';

const Counter = () => {
  // Access the 'count' state from the Redux store using the 'useSelector' hook
  const count = useSelector((state) => state.count);

  // Get the 'dispatch' function from the Redux store using the 'useDispatch' hook
  const dispatch = useDispatch();

  const handleIncrement = () => {
    dispatch({ type: 'INCREMENT' });
  };

  const handleDecrement = () => {
    dispatch({ type: 'DECREMENT' });
  };

  return (
    <div>
      <h2>Count: {count}</h2>
      <button onClick={handleIncrement}>Increment</button>
      <button onClick={handleDecrement}>Decrement</button>
    </div>
  );
};

export default Counter;


4. Connect the store to your React application using the component Provider of react-redux


// index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import store from './store';
import Counter from './Counter';

ReactDOM.render(
  <Provider store={store}>
    <Counter />
  </Provider>,
  document.getElementById('root')
);


With Redux, we can easily manage the state of our React application, even in cases of large-scale applications with multiple interconnected components.


TanStack Query

TanStackQuery or React Query provides a powerful way to manage data fetching and caching, optimizing performance and reducing unnecessary requests. It is especially useful for large-scale applications with complex data requirements.


React Query abstracts the data fetching logic into hooks, which are easy to use and integrate with React components. It manages data in a cache and automatically handles data fetching and invalidation, reducing the need to manage data manually.


Let’s see a simple example of using React Query to fetch data from an API:


  1. Install React Query using npm or yarn:


npm install react-query


2. Create a component that uses React Query to fetch data from an API:


// UserList.js

import React from 'react';
import { useQuery } from 'react-query';

const fetchUsers = async () => {
  const response = await fetch('https://jsonplaceholder.typicode.com/users');
  return response.json();
};

const UserList = () => {
  const { data, isLoading, isError } = useQuery('users', fetchUsers);

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return <div>Error fetching data</div>;
  }

  return (
    <div>
      <h1>User List</h1>
      <ul>
        {data.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default UserList;


In this example, we are using the useQuery hook from React Query to fetch user data from the JSONPlaceholder API. The useQuery hook takes a unique query key users and a function fetchUser that performs the data fetching.


React Query will automatically manage the data, caching the results, and handling loading and error states. Using React Query, you can easily handle complex data fetching requirements, manage pagination, and efficiently update the user interface when data changes.


Apollo Client (for GraphQL)

If your application uses GraphQL for data fetching, Apollo Client is an excellent choice for state management. It provides a caching layer that reduces the need for excessive network requests, improving performance in large-scale applications.


Setting up a GraphQL Server and Client in Next.js


Let’s see a simple example of using Apollo Client:


  1. Install Apollo Client using npm or yarn:


npm install @apollo/client graphql


2. Create an Apollo Client Instance. Next, create an instance of Apollo Client and configure it with the GraphQL endpoint for your server.


// apollo-client.js
import { ApolloClient, InMemoryCache } from '@apollo/client';

const client = new ApolloClient({
  uri: 'https://example.com/graphql', // Replace with your GraphQL server URL
  cache: new InMemoryCache(),
});

export default client;


3. Wrap your application with the ApolloProvider component provided by Apollo Client. This component injects the Apollo Client instance into the React component tree, making it accessible to all components.


// index.js
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import client from './apollo-client';
import App from './App';

ReactDOM.render(
  <ApolloProvider client={client}>
    <App />
  </ApolloProvider>,
  document.getElementById('root')
);


Finally, you can use GraphQL queries in your React components using the useQuery hook provided by Apollo Client. This hook gets the data based on the GraphQL query and automatically updates the component when the data changes.


// ExampleComponent.js
import React from 'react';
import { useQuery, gql } from '@apollo/client';

const GET_USERS = gql`
  query GetUsers {
    users {
      id
      name
      email
    }
  }
`;

const ExampleComponent = () => {
  const { loading, error, data } = useQuery(GET_USERS);

  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {data.users.map((user) => (
          <li key={user.id}>
            {user.name} - {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default ExampleComponent;


Apollo Client manages component caching and updating when data changes, providing a seamless and efficient data management solution for large-scale React applications using GraphQL.

Conclusion

In conclusion, there is no one-size-fits-all solution for state management in large-scale React applications. Each approach has its strengths and weaknesses, and the best choice depends on factors such as application size and complexity, team experience, and specific requirements.


A combination of different state management solutions can be employed in a large-scale application to meet different needs effectively.


It is essential to evaluate the pros and cons of each approach and choose the one that best suits the needs and objectives of the project.



Read more:


Want to connect with the Author?

Love connecting with friends all around the world on X.


Also published here.