loading...

Thinking in hooks

lukeshiru profile image ▲ Lucas Ciruzzi Updated on ・6 min read

Thinking in React 2020 (3 Part Series)

1) Thinking in React: The 2020 version 2) Thinking in hooks 3) One-way data flow: Why?

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 personal 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 avoid doing the same things we did before with different syntax, and actually 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 like this.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 like this.setState did in the past. If you have and 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. To resolve this, we can have a mix between internal state and parent control, by using useEffect:

const Counter = ({
  initialCount = 0,
  count = initialCount,
  step = 1,
  onAdd = () => undefined,
  onSubtract = () => undefined,
  ...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 which 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(listener); 

  // This returned function will be called for cleanup
  return () => {
    // In here we remove the even listener
    button.removeEventListener(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 paren't didn't called 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>
  );
};

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:



<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">

Thank you for writing! I saw some notations that I haven't used myself, so I'll have to keep those in mind. I was surprised to see the shorthand for <Fragment>, only to find out that it has been in React since 2017 :(

I've been trying to into Hooks (I have some projects with classbased components that could benefit from it). So when I got to the part referencing Hooks, I was a little bummed out that the article stopped there. Maybe it would make a nice subject voor a next article.



Thinking in React 2020 (3 Part Series)

1) Thinking in React: The 2020 version 2) Thinking in hooks 3) One-way data flow: Why?

Posted on Apr 14 by:

Discussion

markdown guide
 

Thank you for the article! It's my next read on Hooks :)