TL;DR: This blog discusses how React Hooks and Redux differ in managing the state of React apps. React Hooks excels in simpler projects and component-level states, while Redux is better for complex, large-scale applications. Get insights into their core concepts and best practices for combining both approaches.
State management is important for any modern front-end application using a JavaScript framework or library like React. These frameworks are rendered client-side and follow component-driven development, where the UI is divided into multiple components with their state and shared states.
Redux, which follows the flux architecture of the one-way data flow, is the most popular library. React recommends it for centralized global state management. It’s scalable and suitable for enterprise-grade applications.
Hooks, introduced in React 16.8, allow for state management in the functional component. They are used for component-level state management or self-state-management. There are many different Hooks, like useState(), useEffect(), useContext(), and useReducer().
This article explores the difference between React Hooks and Redux in 2024.
The front-end ecosystem is ever-evolving. As React evolves, developers face choices about managing the state of their applications.
In 2024, understanding the strengths and weaknesses of Redux and React Hooks will help developers build enterprise-level applications that are efficient, scalable, and maintainable. This comparison will help developers make informed decisions based on their project’s specific needs.
There are three types of state management in any modern web application:
All three can be used together in reverse order. For example, if you have implemented Redux, you can still use the module-level and the component-level state.
The beauty of React is that a component bound to a particular state will only re-render if the state updates. Thus, even if your application uses Redux, a component that does not access that state will not re-render unnecessarily.
Let’s see an example of different React Hooks and Redux and then compare them.
React Hooks are a pattern specific to React that is introduced for the functional components. They allow us to use React state and other features, such as observing the different lifecycle states and creating a context within React components.
There are many built-in hooks available in React, and you can also create different custom hooks. However, we will explore the four hooks that are important for state management.
The useState() Hook is used for local state management in React functional components. We define the Hook with a default value, and it returns an array of values, where the first value is the state, and the second value is a function to update the state.
Variables cannot be used to store the values as they get overridden once the component re-renders. React provides the Hook to persist the state.
import React, { useState } from "react"; const Counter = () => { const [count, setCount] = useState(0); return ( <main> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </main> ); }
Once the value of the useState() Hook changes, the component re-renders, and the value is rendered or displayed in the browser.
The useEffect() is a lifecycle Hook which is invoked at three different stages in a component:
This Hook is crucial, as it allows tracking the component lifecycle, such as making network calls to fetch data from a server when the component mounts.
This record then can be stored using the useState() Hook.
import React, { useState, useEffect } from "react"; function FetchData() { const [data, setData] = useState(null); useEffect(() => { fetch("https://jsonplaceholder.typicode.com/todos") // convert the raw data to JSON .then((response) => response.json()) // then set the data .then((data) => setData(data)); }, []); // Empty array means this effect runs once on mount return ( <div> {data ? <pre>{JSON.stringify(data, null, 2)}</pre> : "Loading..."} </div> ); }
The useContext() creates a boundary that provides all the components within the context that can access the state directly rather than having to be passed down in the component tree.
This helps optimize and remove the middleman’s access, who does not even consume the props but rather just passes them down further.
//FeatureFlag.js import React, { useState } from "react"; // Split test context export const SplitTestFlag = React.createContext({}); // split text provider export const SplitTestFlagProvider = ({ children }) => { const [features, setFeatures] = useState({ darkMode: true, chatEnabled: false }); return ( <SplitTestFlag.Provider value={{ features, setFeatures }}> {children} </SplitTestFlag.Provider> ); }; // Component to conditionally render feature const Feature = ({ feature, children, value }) => { const { features } = React.useContext(SplitTestFlag); return features[feature] === value ? children : null; }; // Example const Example = () => { const { features, setFeatures } = React.useContext(SplitTestFlag); return ( <> <Feature feature="darkMode" value={true}> in Dark Mode </Feature> <Feature feature="chatEnabled" value={true}> Chat </Feature> <button onClick={() => setFeatures({ ...features, chatEnabled: true })}> Enable Chat </button> </> ); }; export default function App() { return ( <SplitTestFlagProvider> <Example /> </SplitTestFlagProvider> ); }
The useReducer() is used to manage more complex state logic. React state can hold any type of value available in JavaScript. Managing a nested object can become messy sometimes, and by using the useReducer(), we can implement a Redux-like reducer and implement only what is needed for that action.
import React, { useReducer } from "react"; const initialState = { count: 0 }; function reducer(state, action) { switch (action.type) { case "increment_count": return { count: state.count + 1 }; case "decrement_count": return { count: state.count - 1 }; default: throw new Error("Invalid action"); } } function Counter() { const [state, dispatch] = useReducer(reducer, initialState); return ( <div> <p>Count: {state.count}</p> <button onClick={() => dispatch({ type: "increment_count" })}>+</button> <button onClick={() => dispatch({ type: "decrement_count" })}>-</button> </div> ); } export default function App() { return <Counter />; }
Redux is a predictable state container for centralized state management of JavaScript apps. It follows the Flux architecture to help manage complex states across large applications by enforcing a unidirectional data flow and a strict separation of concerns.
Core Concepts: Actions, Reducers, Store
Actions describe what has happened. They are pure JavaScript functions that return a plain JavaScript object containing an identifier and the payload.
Identifiers help trace what state has to be changed, and the payload is the value that needs to be set to that state, which can be processed further. Refer to the following code example.
const increment = () => ({ type: 'INCREMENT', payload: null }); const decrement = () => ({ type: 'DECREMENT', payload: null });
Reducers are the central managers who decide what should change in the state based on the actions they receive. They can also receive fresh data with the action, which can be processed further and stored in the state.
A reducer is pure JavaScript with a switch case or if-else block to determine the actions and their course. Refer to the following code example.
const initialState = { count: 0 }; function counterReducer(state = initialState, action) { switch (action.type) { case "INCREMENT": return { count: state.count + 1 }; case "DECREMENT": return { count: state.count - 1 }; default: return state; } }
This is the centralized data manager that stores the state. To better manage the state, we can define and manage one or more states in Redux.
import { createStore } from 'redux'; const store = createStore(counterReducer);
Multiple reducers can be combined. Refer to the following code example.
import { combineReducers } from '@reduxjs/toolkit' import todos from './todos' import counter from './counter' export default combineReducers({ todos, counter });
React Hooks: Ideal for managing local component state and side effects. If state management complexity is relatively low in smaller apps, Hooks works well. Refer to the following code example.
function Counter() { const [count, setCount] = useState(0); return ( <div> <p>You clicked {count} times</p> <button onClick={() => setCount(count + 1)}>Click me</button> </div> ); }
Redux: Best suited for large-scale applications where global state management, predictability, and robust debugging tools are essential. Refer to the following code example.
import { createStore } from "redux"; const initialState = { count: 0 }; function counterReducer(state = initialState, action) { switch (action.type) { case "INCREMENT": return { count: state.count + 1 }; case "DECREMENT": return { count: state.count - 1 }; default: return state; } } const store = createStore(counterReducer);
React Hooks: Handles side effects using useEffect().
useEffect(() => { // Perform side effects here return () => { // Cleanup if necessary }; }, [dependencies]);
Redux: Uses middleware like redux-thunk or redux-saga to manage side effects.
const thunkMiddleware = (store) => (next) => (action) => { if (typeof action === "function") { return action(store.dispatch, store.getState); } return next(action); };
React Hooks: Provides abstraction, reduces boilerplate code, and makes components easier to read and maintain. However, managing the state with Hooks in large applications can become challenging without proper organization. Refer to the following code example.
const [state, setState] = useState(initialState); useEffect(() => { // Side effect logic }, []);
Redux: Introduces more boilerplate code due to actions, reducers, and the store setup. This can lead to verbose and complex code, especially in large applications. Refer to the following code example.
const increment = () => ({ type: "INCREMENT" }); const decrement = () => ({ type: "DECREMENT" }); function counterReducer(state = initialState, action) { switch (action.type) { case "INCREMENT": return { count: state.count + 1 }; case "DECREMENT": return { count: state.count - 1 }; default: return state; } }
React Hooks: Managing the state with Hooks in large applications can become challenging without proper organization and conventions. Custom Hooks can help, but they require careful design.
function useCustomHook() { const [state, setState] = useState(initialState); // Custom hook logic return [state, setState]; }
Redux: Designed for scalability, Redux excels in managing complex states across large applications. Its unidirectional data flow and middleware support make it easier to handle large-scale state management.
const rootReducer = combineReducers({ // Combine multiple reducers }); const store = createStore(rootReducer, applyMiddleware(thunkMiddleware));
React Hooks: Functional components with Hooks can offer better performance due to reduced overhead. Hooks also enable fine-grained control over when and how components re-render.
const [state, setState] = useState(initialState); useEffect(() => { // Side effect logic }, [dependencies]);
Redux: While Redux itself performs, the additional overhead of managing actions, reducers, and the store can impact performance in larger applications. Middleware can also introduce performance considerations.
const store = createStore(counterReducer, applyMiddleware(thunkMiddleware));
function Toggle() { const [isOn, setIsOn] = useState(false); return <button onClick={() => setIsOn(!isOn)}>{isOn ? "On" : "Off"}</button>; }
import { createStore, applyMiddleware } from "redux"; import thunk from "redux-thunk"; const initialState = { data: null }; function dataReducer(state = initialState, action) { switch (action.type) { case "SET_DATA": return { ...state, data: action.payload }; default: return state; } } const store = createStore(dataReducer, applyMiddleware(thunk));
Refer to the following code example.
import React, { useState, useEffect } from "react"; import { useSelector, useDispatch } from "react-redux"; import { fetchData } from "./actions"; function DataComponent() { const [localState, setLocalState] = useState(null); const globalState = useSelector((state) => state.data); const dispatch = useDispatch(); useEffect(() => { dispatch(fetchData()); }, [dispatch]); return ( <div> <button onClick={() => setLocalState("Updated Local State")}> Update Local State </button> <p>Local State: {localState}</p> <p>Global State: {JSON.stringify(globalState)}</p> </div> ); }
In 2024, both React Hooks and Redux remain powerful tools for managing state in React applications. React Hooks are beginner-friendly and flexible, making them an excellent choice for smaller applications and component-level state management. Redux provides a robust and predictable state management solution, particularly well-suited for large-scale applications with complex state requirements.
Choosing the right solution depends on your application’s specific needs, the complexity of state management, and the team’s familiarity with each approach. By understanding the strengths and limitations of React Hooks and Redux, developers can make informed decisions to build efficient and maintainable React applications.