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.
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
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;
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: App
, Counter
, 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.
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 increment
and decrement
functions to modify the counter.
Then, the CounterDisplay
and CounterButtons
components consume the context using the useContext
Hook to access the counter value and functions to modify it.
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.
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.
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:
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 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.
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:
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
// 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.
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.