React’s useReducer hook is an incredibly powerful tool for managing state in your React apps. It is especially useful when your app has multiple components that need to work together to manage data and state changes. This hook allows you to centralize state management in a single location, making it easier to debug and maintain your code.
Benefits over useState
UseReducer provides many benefits over the useState hook, especially when dealing with more complicated state management scenarios. It allows for better control over state changes, as you can define custom reducers that keep track of the state changes and respond accordingly. This makes it much easier to debug and modify your state changes throughout the lifetime of your application. It also allows for better code organization as you can clearly separate state management logic from presentation logic.
Example of component before useReducer:
import React, { useState } from "react"; const Contact = () =>{ const [name, setName] = useState(""); const [age, setAge] = useState(0); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const handleSubmit = (e) => { e.preventDefault(); console.log(name, age, email, password); }; return ( <div> <h1>Hello {name}</h1> <p>You are {age} years old</p> <p>Your email is {email}</p> <p>Your password is {password}</p> <form onSubmit={handleSubmit} className="form"> <input type="text" placeholder="name" value={name} onChange={(e) => setName(e.target.value)} /> <input type="number" placeholder="age" value={age} onChange={(e) => setAge(e.target.value)} /> <input type="email" placeholder="email" value={email} onChange={(e) => setEmail(e.target.value)} /> <input type="password" placeholder="password" value={password} onChange={(e) => setPassword(e.target.value)} /> <button>Submit</button> </form> </div> ); }; export default Contact;
Example of component with useReducer:
import {contactReducer, initialState} from '../reducers/ContactReducer'; import {useReducer} from 'react const Contact = () => { const [state, dispatch] = useReducer(contactReducer, initialState); const handleSubmit = (e) => { e.preventDefault(); console.log(state); }; return ( <div> <h1>Hello {state.name}</h1> <p>You are {state.age} years old</p> <p>Your email is {state.email}</p> <p>Your password is {state.password}</p> <form onSubmit={handleSubmit} className="form"> <input type="text" placeholder="name" value={state.name} onChange={(e) => dispatch({ type: "SET_NAME", value : e.target.value }) } /> <input type="number" placeholder="age" value={state.age} onChange={(e) => dispatch({ type: "SET_AGE", value : e.target.value }) } /> <input type="email" placeholder="email" value={state.email} onChange={(e) => dispatch({ type: "SET_EMAIL", value : e.target.value }) } /> <input type="password" placeholder="password" value={state.password} onChange={(e) => dispatch({ type: "SET_PASSWORD", value : e.target.value }) } /> <button>Submit</button> </form> </div> ); }; export default Contact;
Managing State Updates
The utilization of useReducer to update state can be done in three different ways- by returning a fresh state object, amending the current state, and deploying the spread operator.
Returning a new state object
When returning a new state object, you will create a new instance of the state object and then assign it to the updated state object. This approach ensures that the state remains immutable, meaning that any changes to the original state object will not be reflected in the updated state object. Not only is this approach typically more efficient than mutating the existing state, but it can also be a much faster method.
Mutating the current state
When mutating the current state, you will update the current state object and assign the updated object to the new state. Although this approach is easier to code, it can be detrimental as its output may be unpredictable. Furthermore, mutating the current condition will cause a discrepancy between different render cycles, making it alluring to misunderstandings and unexpected errors.
Using the spread operator
The spread operator can be used to efficiently create a copy of the current state object and then make any necessary changes. By adopting this methodology, the code becomes simpler to read and write while also ensuring that the state remains immutable. This approach allows for better code organization as all of the state changes can be made in a single location.
Advanced usage of useReducer
Passing Additional Data with Actions: You can pass additional data with actions by providing an object that contains the action type and any other data needed. This can be done by adding a payload property to the action object. For example:
const action = { type: 'ADD_ITEM', payload: { item: 'My New Item' } }
Using Context to Access the State and Dispatch Function
You can use context to access the state and dispatch function from a component. To do this, you need to create a context object and then use the useContext hook to access the state and dispatch function from the context object. For example:
const MyContext = React.createContext(); function MyComponent() { const context = useContext(MyContext); const { state, dispatch } = context; // Use state and dispatch here }
Using useEffect in Conjunction with useReducer
You can use the useEffect hook in conjunction with useReducer to perform side-effects based on the state. This can be done by using the useEffect hook with an empty array as the second argument and accessing the state from the useReducer hook. For example:
useEffect(() => { // Perform side-effects based on the state }, [state])
Additionally if you want to get extra, you can use React Query library in conjunction with useReducer for data fetching and caching. Similarly this can be done by using the useQuery hook within the useReducer callback function. This will allow you to fetch and cache data based on the state. For example:
useEffect(() => { const query = useQuery(['myData', state], async () => { const response = await fetch('/my-data'); return response.json(); }); dispatch({ type: 'SET_DATA', payload: query.data }); }, [state]);
Although I would note that using React Query in this case can be somewhat of a tedious idea depending on the use case. React Query of course is helpful for data fetching and caching, especially when you need to fetch data based on the state. However, it may be overkill for some simpler use cases, so you should consider the best approach for your specific situation.
Best practices and tips when working with useReducer
When utilizing useReducer, it is critical to adhere to the best practices and strategies that guarantee your code remains maintainable and testable.
Always use functional programming
Always use functional programming when writing reducers. This will ensure that the state remains immutable and will make your code more efficient and easier to read. By utilizing functional programming, we can avoid accidentally mutating the state and make debugging easier.
Avoid using mutating operations as much as possible.
This can lead to unpredictable behavior and make it more difficult to debug and maintain your code.
Keep your reducer pure.
The reducer must be a pure operation that calculates the upcoming state solely based on the current state and action. It should never perform any side-effects, like calling an API or modifying the DOM.
Use action constants
To optimize your code and prevent errors, replace strings with constants for all action types – this will make it easier to read and comprehend!
Use action creators:
To make it easier to understand the intent of an action and to keep the data payload consistent, use action creators that return action objects. If necessary, use the action object to pass additional data to the reducer. This will make it easier to respond to different types of actions in a more dynamic way
Use the spread operator
When updating the state, use the spread operator to create a new object instead of mutating the existing state. As mentioned above, the code becomes simpler to read.
Test your reducer
Unit test your reducer to ensure it behaves as expected and to catch any bugs early.
Remember as mentioned, use useReducer in conjunction with React Context and useEffect. This will make it easier to access the state and dispatch functions from different components, and will make it easier to handle asynchronous actions.
Final Thoughts
working with the useReducer hook in React can be a powerful tool for managing complex state and logic in your components. It is important to keep in mind best practices such as keeping your reducer functions pure, utilizing the useCallback hook to optimize performance, and avoiding unnecessary state updates. Additionally, it is beneficial to use a centralized store such as Redux for global state management, and to use the useEffect hook to handle side effects. Also remember that using the UseReducer hook in some instances can just be overkill, especially when only a couple useStates are being used. As you continue to work with the useReducer hook, always keep in mind the goal of making your code more predictable, maintainable, and easy to test.