Take into consideration that this article is a few years old at this point, so you might want to look for a more recent version with modern practices. At the time of writing of this message the oficial React Docs are finally being updated, so maybe when you read this they are out of beta already. Remember to always look for up-to-date tutorials and documentation.
Last year, the React team introduced hooks (not to be confused with hocs) and they changed drastically the way we think and create components. From my point of view, that change was for the better, but obviously, it introduced some migration issues for people used to class components. The approach in articles like this one then became "how to migrate from lifecycle methods to hooks", but the idea is to avoid doing the same things we did before with different syntax and do things differently.
Forget about lifecycle methods
This is by far the hardest step but is the first one we need to do. Is harder to wrap our heads around hooks while thinking about them as "the new lifecycle methods". We should avoid thinking stuff like:
useState
is likethis.setState
, right?
Hooks are different, and they need to be used differently. The architecture of our apps in some regards will have to change when we are migrating from a heavily class-based to a functional-based, but that's ok.
Think in hooks
Let's start with the classic "Counter" example, without any internal state. This could be separated into components like <AddButton />
, <SubtractButton />
and so on, but let's keep it simple:
const Counter = ({ count = 0, onAdd, onSubtract, ...props }) => (
<div {...props}>
<span>{count}</span>
<button onClick={onAdd}>+</button>
<button onClick={onSubtract}>-</button>
</div>
);
This component is good enough to be used, and as I mentioned in my previous article we should avoid adding state to every component. But this article is about hooks, so let's add some internal state to Counter
:
const Counter = ({ initialCount = 0, step = 1, ...props }) => {
const [count, setCount] = useState(initialCount);
return (
<div {...props}>
<span>{count}</span>
<button onClick={() => setCount(count + step)}>+</button>
<button onClick={() => setCount(count - step)}>-</button>
</div>
);
};
useState
returns a tuple (an array of two elements), the first one being the current state, and the second being a function to update that state. You can give them the name you want, in the example, count
is the current state, and setCount
is the function to update that value.
But isn't this the same as doing this.setState?
β A clueless react dev.
The answer is no. The hook useState
is very different:
- It triggers the re-render of the component only if the value is different (so immutability is key when using it).
- Is meant to be used for small values, not huge objects like the ones we saw in several class components in the past. If you need another value, add another
useState
line. - When calling the state setter (in the example,
setCount
), the previous state is replaced with the new one, is not merged likethis.setState
did in the past. If you have an object there and you want to update a value, you need to do{ ...state, value: "new value" }
.
The usage of the above example would be something like this:
const App = () => (
<>
Default counter: <Counter />
Counter with initial value: <Counter initialCount={10} />
Counter with even numbers only: <Counter step={2} />
</>
);
This still has one "important" issue: The parent has loose control over this component, so it doesn't know when it changed and can't update the value once it set the initial. That's why I always insist on not having an internal state and have as much stateless components as possible (Counter
is the perfect example of a component that doesn't need an internal state). Yet, just to keep showing you hooks, we can resolve this with a mix between internal state and parent control, by using useEffect
:
const Counter = ({
initialCount = 0,
count = initialCount,
step = 1,
onAdd,
onSubtract,
...props
}) => {
const [countState, setCountState] = useState(initialCount);
useEffect(() => setCountState(count), [count]);
return (
<div {...props}>
<span>{count}</span>
<button
onClick={event => {
onAdd?.(event);
return event.isDefaultPrevented()
? undefined
: setCount(count + step);
}}
>
+
</button>
<button
onClick={event => {
onSubtract?.(event);
return event.isDefaultPrevented()
? undefined
: setCount(count - step);
}}
>
-
</button>
</div>
);
};
useEffect
takes 2 parameters, the first one is a function that will run every time the component renders or something in the second parameter changes, and the second parameter is a "dependency list". This list has some values that will make the function in the first parameter run if they change. You can provide an empty array there and it will only run on "mount" (first render), and if you don't provide a dependency list, then it runs in every render of the component. useEffect
exists to run "side-effects", and the "side-effect" in this example is to update the internal countState
if the count
parameter changes from the parent. So now it has an internal state, but also count can be updated from the upper level.
Sometimes "side-effects" need a cleanup (stop a running fetch, remove an event listener, and so on), so if you return a function in your useEffect
, that will be called when the effect is being dismounted. A simple example of that:
useEffect(() => {
const button = document.querySelector("button");
const listener = () => console.log("Button clicked!");
// This is ugly, but we add a listener to a button click
button.addEventListener("click", listener);
// This returned function will be called for cleanup
return () => {
// In here we remove the even listener
button.removeEventListener("click", listener);
};
}, []); // Empty dependency list, so it only runs on mount
In the event handlers for the buttons, we have a trick in which we call the event handlers provided by the parent first. If those even handlers called preventDefault
at some point, then we don't run the "default" behavior of updating the count value (return undefined
), and if the parent didn't call preventDefault
, then we just update the state.
This seems complicated at first, but if you think about it, with the class approach this needs a mix of several things (componentDidMount
, componentDidUpdate
, shouldComponentUpdate
and so on) that are all resolved by just useEffect
.
Take it further
We can take this further, replacing redux with useReducer
. This hook emulates the behavior of redux:
// constants.js
const COUNTER_ADD = "COUNTER_ADD";
const COUNTER_SUBTRACT = "COUNTER_SUBTRACT";
const COUNTER_SET = "COUNTER_SET";
// reducers.js
const counterReducer = (state = 0, action) =>
({
[COUNTER_ADD]: state + (action.payload ?? 1),
[COUNTER_SUBTRACT]: state - (action.payload ?? 1),
[COUNTER_SET]: action.payload ?? state
}[action.type] ?? state);
// actions.js
const counterAdd = (payload = 0) => ({ type: COUNTER_ADD, payload });
const counterSubtract = (payload = 0) => ({ type: COUNTER_SUBTRACT, payload });
const counterSet = payload => ({ type: COUNTER_SET, payload });
// Counter.js
const Counter = ({
initialCount = 0,
count = initialCount,
step = 1,
onAdd = () => undefined,
onSubtract = () => undefined,
...props
}) => {
const [countState, setCountState] = useReducer(
counterReducer,
initialCount
);
useEffect(() => setCountState(counterSet(count)), [count]);
return (
<div {...props}>
<span>{count}</span>
<button
onClick={event => {
onAdd(event);
return event.isDefaultPrevented()
? undefined
: setCount(counterAdd(step));
}}
>
+
</button>
<button
onClick={event => {
onSubtract(event);
return event.isDefaultPrevented()
? undefined
: setCount(counterSubtract(step));
}}
>
-
</button>
</div>
);
};
Create your own hooks
We took it one step further, why not two? That code has some duplicated stuff that could be easily moved to custom hooks. The convention is to prepend the name of our hooks with use
. Let's crate a hook called useEventOrState
, to move that logic away from the component and make it easy to implement in other components:
// useEventOrState.js
const useEventOrState = (eventHandler, stateSetter) => callback => event => {
eventHandler(event);
return event.isDefaultPrevented()
? undefined
: stateSetter(callback(event));
};
// Counter.js
const Counter = ({
initialCount = 0,
count = initialCount,
step = 1,
onAdd = () => undefined,
onSubtract = () => undefined,
...props
}) => {
const [countState, setCountState] = useReducer(
counterReducer,
initialCount
);
const addHandler = useEventOrState(onAdd, setCountState);
const subtractHandler = useEventOrState(onSubtract, setCountState);
useEffect(() => setCountState(counterSet(count)), [count]);
return (
<div {...props}>
<span>{count}</span>
<button onClick={addHandler(() => counterAdd(step))}>+</button>
<button onClick={subtractHandler(() => counterSubtract(step))}>
-
</button>
</div>
);
};
The good thing about hooks is that you can move all sorts of logic away from components, making them easier to test and reuse. We can keep optimizing the example above, and a useCounterReducer
if we have several components using the same state:
// useCounterReducer.js
const useCounterReducer = (initialCount = 0) =>
useReducer(counterReducer, initialCount);
// Counter.js
const Counter = ({
initialCount = 0,
count = initialCount,
step = 1,
onAdd = () => undefined,
onSubtract = () => undefined,
...props
}) => {
const [countState, setCountState] = useCounterReducer(initialCount);
const addHandler = useEventOrState(onAdd, setCountState);
const subtractHandler = useEventOrState(onSubtract, setCountState);
useEffect(() => setCountState(counterSet(count)), [count]);
return (
<div {...props}>
<span>{count}</span>
<button onClick={addHandler(() => counterAdd(step))}>+</button>
<button onClick={subtractHandler(() => counterSubtract(step))}>
-
</button>
</div>
);
};
Closing thoughts
Simple components like the one used in the examples for this article are meant to keep being simple, so please DON'T EVER DO THIS with components such as this. As I mentioned in my previous article, you should try to keep your components simple (so they are easy to test and maintain), and only add state where is needed (generally in "container" components that set the state for everyone else, maybe using the Context API if needed). In short, KISS and DRY.
That's it, thanks for taking the time to read this!
Special thanks to Timo Grevers for the inspiration for this post.
Top comments (8)
Hey, given this is for beginners try to not promote bad patterns like having a local state that is a direct clone of props and is in sync with the props using an effect. People will take that home as a React idea when it's actually discouraged.
Hi Aviral! I'm not promoting bad patterns:
Counter
is good enough without state, but this article wanted to show how you can add local state with hooks, even ifCounter
doesn't need it.TL;DR: Read the closing thoughts at the end of this article.
Yeah I get that but that doesn't change the fact that the code example still promotes a terrible anti pattern. I would rather use a code example that doesn't have to use an anti-pattern as a tool of teaching.
I chose to use state directly on a known component like the
Counter
and keep it simple, instead of creating a container/top level component just to show this. The post is an intro to hooks and how to migrate from life cycle methods, not about "best practices with hooks" πSo go ahead! Create a similar post with examples that you consider "good" and without "anti-patterns". The site is full of posts like mine, so the same time that you're using to comment in every post on the topic to highlight anti-patterns, can be invested in actually show it in a post of yours (turning those "I would" into actions) π
I'm sorry; please don't take it so personally; I'm just saying that this post is one of the top results on Google for thinking in react so it shouldn't take much energy or time to make sure that newbies don't get bad ideas. I actually liked this post a lot; wanted to send this over to someone who hated React and now wants to try it instead of Angular; and I couldn't send this over just because of the examples and how they do exactly what we have to set rejection rules in code reviews for. I'll try to find some time to write docs around this; and I'll link it here if I do; thanks!
Don't worry! I didn't took it personally at all! π ... I'm just saying that this site is great for sharing knowledge about code, and is far better to have different perspectives in different posts instead of continuously fix a pre-existing one.
I appreciate the time you put into responding to point out stuff that could be confusing, but I think that creating a new post would be more impactful.
That's the same thought that drove me to make this series of post (because the original "thinking in React" felt super outdated) π
Thank you for the article! It's my next read on Hooks :)
Tks man!