📌 It's heartbreaking to see the suffering of so many children. We stand with Palestine 🇵🇸 and pray for peace!

Understanding React Local State The Right Way!

13 min read
·
06 May, 2024
·
0

If you are using ReactJS to build a website then you must have used useState to manage state. States are a great way to manage and render dynamic content in React. States can change over time, and React takes care of components to render with the state.

The simplest way to manage states in React is using hooks. The two hooks are useState and useReducer! Using these hooks we can manage the state inside any component easily. But these are local states, the state maintained inside one component cannot be used in some other components, and that’s why it is named a local state.

On the other hand, we can also manage global state using props drilling or Context API or even using 3rd party state management libraries. But in this article let’s focus on how to manage the local state the right way!

Let’s see a basic usage of useState:

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(0);
return (
<>
<h1>The count is {count}</h1>
<button onClick={() => setCount(count + 1)}>Add +1</button>
</>
);
};
export default Component;

The count is managed locally and whenever the button is clicked the count is updated. This is pretty straightforward. But how about we extract the logic and make use of custom hooks?

Custom Hooks

Let’s create a useCount custom hook and manage the state in that custom hook. We will use the same example as above:

useCount.jsx
import { useState } from "react";
export const useCount = () => {
const [count, setCount] = useState(0);
return [count, setCount];
};
Component.jsx
import { useCount } from "./useCount";
const Component = () => {
const [count, setCount] = useCount();
return (
<>
<h1>The count is {count}</h1>
<button onClick={() => setCount(count + 1)}>Add +1</button>
</>
);
};
export default Component;

We made a few changes and our code is still working. With this, we now have a more clearer name — useCount and now the Component is independent of the implementation of useCount.

Convention

We initialize useState as follows:

const [count, setCount] = useState(0);

Here we have two things: count and setCount. The count is a number and setCount is a function. Always remember that the first index in the array will always be an array and the 2nd index will always be a setter function. If we only want value and not function we can also write like this:

const [count] = useState(0);

This way we will get access to count value alone. We can write anything inside the array, any name, any variable, everything is acceptable:

const [count, setCount] = useState(0);
// OR
const [myCount, setMyCount] = useState(0)
// OR
const [getCount, setCount] = useState(0)
// OR
const [count, increase] = useState(0)

Whatever we write will not affect the working of useState but as a developer, there are some conventions that one should follow. If we write [count, increase] = useState(0), then other developers will never understand that increase is a setter function. Thus, a good practice is to always add a set as a prefix to a setter function. [count, setCount], [percentage, setPercentage], [isAdmin, setIsAdmin], … these are good practices.

const [setCount, count] = useState(0);

Even if you write like this, there will not be any errors but this is such a bad practice. In this setCount is a number and count is a setter function but no one will know this except you. Thus there are some conventions that we should always follow.

Updating useState with direct value

We know that setter functions are used to update the states. And whenever the state is updated, the component is re-rendered. Look at the following example:

<button onClick={() => setCount(count + 1)}>Add +1</button>

Here we are simply updating the count value by 1 whenever the button is clicked. But what about the following following code?

<button onClick={() => setCount(1)}>Add +1</button>

If we click the button, it will trigger the Component to re-render with count=1. What would happen if we clicked the button again? It will invoke setCount(1) again, but as it is the same value it gets ignored and the Component won’t re-render. Let’s see another example to better understand this:

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(0);
const updateCount = () => {
setCount(1);
};
return (
<>
<h1>The count is {count}</h1>
{console.log("Rendered")}
<button onClick={updateCount}>Add +1</button>
</>
);
};
export default Component;

If we click the button 5 to 10 times, we will see that ‘Rendered’ is printed in the console only twice. This means the component has not re-rendered 5 to 10 times as React is getting the same state value on the button click.

But if we initialize the state with an object instead of a number, then the case will be different. Look at the below example:

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState({ value: 0 });
const updateCount = () => {
setCount({ value: 1 });
};
return (
<>
<h1>The count is {count.value}</h1>
{console.log("Rendered")}
<button onClick={updateCount}>Add +1</button>
</>
);
};
export default Component;

We are setting the same object {value: 1} whenever the button is clicked so React shouldn’t re-render the Component right? But no, if we click the button 10 times, the component will be rendered 10 times, because every time we click the button, a new object is created, and although the object is the same but their references are different so React will always assume that both the object is different and therefore state is updated and Component is re-rendered. That’s why the state should be simple like strings or numbers and only use objects or arrays whenever when there is no option.

Updating useState with function

Another way of updating the state is using functions.

setCount( (prevCount) => prevCount + 1)

Setter functions will always have access to previous values (this prevCount is also known as the latest count value). So we can use a function this way and can update the state. We don’t need to have to write prevCount, we can write anything. Here is one more example for better understanding.

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(0);
const updateCount = () => {
setCount((c) => c + 1);
};
return (
<>
<h1>The count is {count}</h1>
<button onClick={updateCount}>Add +1</button>
</>
);
};
export default Component;

An Interesting Example

We have seen two ways to update the state, one is direct modification using values ad another is using function. So what’s the difference between them. To understand this, first we need to understand that whenever we update a state, it is not updated immediately. Whenever we write useCount(count + 1) or useCount( (c) => c + 1), React will acknowledge and will schedule the update. And the value is updated in the next render and not immediately. Let’s see a simple example to understand this.

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(0);
const updateCount = () => {
setCount(count + 1);
for (let i = 0; i <= 100; i++) {
if (i == 100) {
console.log("Loop Ended");
}
}
console.log(count);
};
return (
<>
<h1>The count is {count}</h1>
<button onClick={updateCount}>Add +1</button>
</>
);
};
export default Component;

When the button is clicked, updateCount function is invoked. Here we are using setCount(count + 1) then a for loop and then we are printing the value of count. If we run this, we will see ‘Loop Ended’ is printed and count value is printed. Now we can observe, that even after the loop, the count value is still 0 and not 1. This is because React has acknowledged that the count value needs to be updated to 1 but it will only update it in the next render and not immediately. This is why React maintains two values viz, count and latestCount. Please note that the implementation of useState is quite complex and we are just simplifying it to better understand it.

// React has count and latestCount
setCount(count + 1)
// Here count refers to the count value.
setCount( (c) => c+1)
// Here c refers to the latestCount value.

React updates the states in batches that’s why it’s important to understand how we are updating the states. See the following example:

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(0);
const updateCount = () => {
setCount(count + 1); // latestCount = 0 + 1 = 1
setCount(count + 1); // latestCount = 0 + 1 = 1
setCount(count + 1); // latestCount = 0 + 1 = 1
};
return (
<>
<h1>The count is {count}</h1>
<button onClick={updateCount}>Add +1</button>
</>
);
};
export default Component;

When we clicked the button, we called setCount(count + 1) and said React to update the count by 1. So React will update the latestCount value from 0 to 1, but the count value will not updated immediately. Now, we call setCount(count + 1) 2nd time, and this time we tell React to update the count, so React will take the value of count (which is still 0 because the count has not been updated yet) and will add 1 and then assign to latestCount. Now latestCount is again 1, and the count is still 0. Then we call setCount(count + 1) the third time, and again this time, we are passing (count + 1) but as we know, React updated latestCount, not count, because count gets updated in batches, and the final value is updated in the next render. So even the third time, we pass (count + 1) where the count is still 0, and the latestCount is 1.

So even if we call setCount(count + 1) three times or 100 times, the count value will be increased by one only. This is because the setCount function uses (count + 1), where the count is passed as 0 every time.

// Writing this is like
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
// Writing this
setCount(0 + 1);
setCount(0 + 1);
setCount(0 + 1);

But this is not the case if we update the state with functions. Because with the function, we have access to the latestCount and not count. Let’s see an example:

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(0);
const updateCount = () => {
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
};
return (
<>
<h1>The count is {count}</h1>
<button onClick={updateCount}>Add +1</button>
</>
);
};
export default Component;

Here, we clicked the button and updated the state using setCount((c) => c + 1). React will acknowledge this and update the latestCount from 0 to 1, but the count value is still 0. Then, we again passed setCount((c) => c + 1) and told React to update the state. Now, the latestCount becomes 2, and the count remains 0. But how latestCount becomes 2? It’s because we update the state using a function. (c)=> c + 1 This is passed in the setState, and here, c is not count, c is referred to as latestCount, so when the setState is called 2nd time with the latestCount, the latestCount value is increased. Then again, we called setState 3rd time, so the latestCount is updated to 3 from 2. Thus, the final count value becomes 3 in the next render.

// Here c is reffered as latestCount
// Writing this is like
setCount((c) => c + 1);
setCount((c) => c + 1);
setCount((c) => c + 1);
// Writing this
setCount((0) => 0+ 1);
setCount((1) => 1+ 1);
setCount((2) => 2+ 1);

That is why if you update the state using a value, i.e., calling setState(count + 1) 100 times, the final value will only be increased by 1 and not by 100 because every time, you are passing count in setState. But if you update the state using functions, i.e., calling setState((c) => c + 1) 100 times, then the final value is updated by 100 and not by 1 because here you are passing latestCount (c)and not the count value every time.

The React team, renowned for their expertise, has comprehensively explained this concept in their latest blog. This resource can be invaluable in deepening your understanding of updating state in React. You can learn more about this here .

Updating useState with lazy initialisation

We can initialize useState with a function that will be evaluated only in the first render. We can do something like this:

import { useState } from "react";
const Component = () => {
const [count, setCount] = useState(() => 0);
return (
<>
<h1>The count is {count}</h1>
<button onClick={() => setCount(count + 1)}>Add +1</button>
</>
);
};
export default Component;

This is known as lazy initialization in React, where we pass a function to useState instead of an initial state value. This is particularly useful when the initial state requires heavy computation or when it depends on props that might change over time. It helps improve the app’s performance by delaying the computation until needed. The above example is ineffective because returning 0 doesn’t require much computation.

Understanding useReducer

Now that we have learned about useState, it’s time to dive deep into useReducer and its usage. useReducer is also another option for managing the React state. When your app grows and has a vast number of states, it could be more effective to use 10 or 20 useState; instead, we can use useReducer to manage the app’s huge state. Let’s see a simple usage of useReducer:

import { useReducer } from "react";
const reducer = (state, action) => {
if (action.type == "INCREMENT") {
return { ...state, count: state.count + 1 };
}
if (action.type == "DECREMENT") {
return { ...state, count: state.count - 1 };
}
return state;
};
const Component = () => {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<h1>The count is {state.count}</h1>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
<button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
</>
);
};
export default Component;

useReducer takes two parameters: reducer and initialState. We have defined our reducer function above, and our initial state is {count: 0} . The reducer function takes the state and the action. We can use if-else or switch statements and modify our state based on your action type. If the action type is INCREMENT, then we need to increase the count value by 1, and if the action type is DECREMENT, then we need to decrease the count value by 1.

Most people think that initialState should be an object but it’s not true. you can pass any primitive data type as well such as numbers or strings. Here is one example:

const reducer = (bonus, rating) => {
console.log(rating);
if (rating < 3) {
// Low rating so no bonus
return bonus;
}
if (rating < 0 || rating > 10) {
return "Rating is invalid";
}
if (rating > 3 && rating < 11) {
return bonus * rating;
}
};

Updating useReducer with lazy initialisation

You can pass the init function as a third parameter in useReducer if you want to initialize the useReducer lazily. The parameters needed for the init function should be passed as a second parameter in useReducer. Here is an example:

import { useReducer } from "react";
const init = (count) => ({ count: count });
const reducer = (state, action) => {
if (action.type == "INCREMENT") {
return { ...state, count: state.count + 1 };
}
if (action.type == "DECREMENT") {
return { ...state, count: state.count - 1 };
}
return state;
};
const Component = () => {
const [state, dispatch] = useReducer(reducer, 0, init);
return (
<>
<h1>The count is {state.count}</h1>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
<button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
</>
);
};
export default Component;

Implementing useState with useReducer

We have learned useState and useReducer separately. But to understand these deeply, let’s ask: Can we implement useState using useReducer?

It’s 100% possible to implement the useState using useReducer, and it is said that useState is implemented internally using useReducer. Please note that what we are implementing is a simple version, and React teams have implemented it more efficiently. Here is an implementatio code.

useMyState.jsx
import { useReducer } from "react";
const reducer = (currentState, newState) => {
if (typeof newState == "function") {
return newState(currentState);
} else {
return newState;
}
};
export const useMyState = (initialState) => {
return useReducer(reducer, initialState);
};

Now if we want to use it in our app, we can use it like this:

Component.jsx
import { useReducer } from "react";
const reducer = (currentState, newState) => {
if (typeof newState == "function") {
return newState(currentState);
} else {
return newState;
}
};
export const useMyState = (initialState) => {
return useReducer(reducer, initialState);
};

This is pretty straightforward, but let’s understand if you need to. We know that useState provides count and setCount, where setCount is a setter function. We can update the state by setCount(count + 1), where we are passing direct value in the useState. Otherwise, we can also pass the function to update the state, such as useState((prevCount) => prevCount + 1). So, in the reducer function, we will first identify whether we are passing a function or direct value. If we pass direct value, we will update the state, or else if we pass the function, then we will call that function with the currentState as a parameter.

We have seen how easily we can implement our custom useState using useReducer. Now let’s ask: Can we do vice-versa? Can we implement useReducer using useState?

Implementing useReducer with useState

Surprisingly, it is possible to implement useReducer with useState. Here is an example:

useMyReducer.jsx
import { useState } from "react";
export const useMyReducer = (reducer, initialState) => {
const [state, setState] = useState(initialState);
const dispatch = (action) => {
const newState = reducer(state, action);
setState(newState);
};
return [state, dispatch];
};

Now let’s see the usage:

Component.jsx
import { useMyReducer } from "./useMyReducer";
const reducer = (state, action) => {
if (action.type == "INCREMENT") {
return { ...state, count: state.count + 1 };
}
if (action.type == "DECREMENT") {
return { ...state, count: state.count - 1 };
}
return state;
};
const Component = () => {
const [state, dispatch] = useMyReducer(reducer, { count: 0 });
return (
<>
<h1>The count is {state.count}</h1>
<button onClick={() => dispatch({ type: "INCREMENT" })}>+1</button>
<button onClick={() => dispatch({ type: "DECREMENT" })}>-1</button>
</>
);
};
export default Component;

Let’s understand the implementation. Our custom useReducer will also need a reducer function and an initialState. Whatever initialState is passed, we will set it and maintain the state using React’s useState. Our useReducer hook will return the state (which we are maintaining using React’s useState and dispatch function). We also created our dispatch function, which takes the currentState and action. Since the action is an object, we will pass that action on to the user-created reducer. As we know, a reducer takes state and action object, and based on the action type, it returns the modified state object, so we will store that new state in the variable newState. Then, we will pass the latest state to setState, which will update the state; hence, the overall state gets updated. This might seem unclear, but if you have hands-on useState and useReducer, you can understand the code easily.

We have come to an end, and we have learned the best practices related to useState and useReducer. We have also learned how to implement useState with useReducer and vice versa. I am pretty sure if the interviewer asks you questions on useState and useReducer, you will kill it!

If you've enjoyed reading this blog and have learnt at least one new thing, do subscribe to recieve updates whenever I post a new article directly to your inbox and do share on Twitter with your friends.