picture of a react hook

React Series Part 1: Hooks

This post assumes you know the basics of modern JavaScript, React and the concepts around it.

What are hooks and why do they matter?

The short version is that hooks allow you to write function components, while still using functionality and logic previously only available to class components. This includes lifecycle methods and state. Hooks offer a new way to create re-usable, isolated pieces with logic and state. Certain cases that previously needed to be handled with Higher Order Components or Render Props, can now often be solved by reaching for a hook or two.

Common or repetitive pieces of logic can now be extracted from components and made into reusable hooks, and all of them behave and follow the same ”rules of hooks”. Complex classes that might be hard to read and test can often be made a lot simpler and shorter, with your hook providing the main logic. However, unlike HOC and render props, they don’t cause nesting in your component trees. We also get rid of a few nuances developers face with JavaScript itself – classes, inheritance and how this works. Hooks are only called from React components, which allows a specific hook’s state to be associated with the actual function that called it.

From Classes To Functions

This is not a migration guide, but rather a quick look at what usually changes when you switch from classes to function components with hooks. 

Hooks are very handy and can be fun to write. However, at the end of the day they allow you to do the same thing as before but in a different way. You shouldn’t feel the need to rewrite everything with hooks. Instead, consider trying them out when writing new components, or when making changes to old ones. You might notice that you can get rid of some of the repetitive logic littered around your components.

The actual process of converting a class component is pretty straight forward. Let’s list which parts change when rewriting a class to use hooks;

  • The constructor, super and this are gone.

Function components don’t have a class instance – that means there is no class constructor, no “this”, and no super call. You just pass props to your component and that’s it.

But how does React know which function called which hook and which function uses which state? React keeps track of which component called the hook using an internal list of ”memory cells” associated with each component. It’s essentially just a JavaScript object under the hood, you don’t really have to worry about it. 

  • Lifecycle methods are replaced by the useEffect hook

Unless you have a very specific situation, you can just use useEffect, which is called during different lifecycle methods. This depends on how and what dependencies you pass to it. More on that shortly.

  • The render function is not needed any more.

Function components return their function body, they don’t need a separate render function.

Rules Of Hooks

While you’re now just writing functions instead of classes, there are still two rules you have to remember to follow;

  • Only Call Hooks from React Functions

This one is easy enough. You cannot call hooks from classes or from plain utility functions. There must be a React Function that actually calls the hooks.

  • Don’t Call Hooks inside loops, conditions, or nested functions

Only call hooks at the top level of components. Placing hook calls inside conditionals or loops might result in them being called in a different order when the component renders. By keeping them on the top level we ensure that it doesn’t happen.

The rules are simple but if you’re used to classes you’ll find yourself breaking them every now and then when starting out. There is an eslint plugin to help you enforce these rules. The plugin also verifies the list of dependencies for your hooks.

The Built-In Hooks

React offers ten different hooks by default but as long as you know your way around React you will find them relatively easy to learn and use. While React has all these built-in hooks, you’ll spend most of your time working with only 3-4 of them. You can combine and use them together to create custom hooks. If you need to optimize or handle a specific edge case scenario, there are also specialty hooks which you can use. The built-in hooks are all prefixed with ”use”, and you should be following this practice when writing your own hooks as well.

Let’s run through some of the built-in hooks and see which hook to use in what situation. I’ll also mention some pitfalls you can run into when using them.

useState

The most basic hook, comparable to this.setState from class components with a minor difference being how this.setState merges objects, while useState replaces the whole object. 

useState takes an argument that it uses to set its initial value. Calling useState returns two items. The first item is the current state value, and the second is a function to update it. You’re free to name them how you want, but the common practice is to prefix the updating function with a “set”.

const TrivialInput = () => {
  const [firstName, setFirstname] = useState('')
  return (
    <input
      type={'text'}
      onChange={e => setFirstName(e.target.value)}
      value={firstName}
    />
  )
}

Note that the first argument, firstName is really just a string, not a magic proxy or a data binding hack – it’s just a normal variable. Calling setFirstName with the same value does not cause a re-render. Note that updating the state with useState will replace the whole state. If you have an object with multiple properties, you need to use something like object spreading to keep the rest of the properties or they will not be part of the next state. 

You can also access the previous state with the setter function.

export const NumberButton = () => {
  const [count, setCount] = useState(0)
  return (
    <>
      <p>This button has been pressed {count} times.</p>
      <button onClick={() => setCount(prev => prev + 1)}>
        {'Increment'}
      </button>
    </>
  )
}

You may also provide the useState with a function. It will be executed only on the initial render, making it useful if setting the initial state is expensive; 

// Bad
const [state, setState] = useState(expensiveFn(props))

// Good
const [state, setState] = useState(() => expensiveFn(props))

If you need to manipulate more complex states or need something more flexible than what useState provides, you should use useReducer which I will present later on. 

useEffect

Like the name implies, this hook accepts possibly effectful or imperative code. This is the only place where you should put that logic when using function components. It’s good for side effects like logging, fetching data, subscriptions and so on. Code that needs to run during specific stages of the react lifecycles is put here. You can actually write multiple effect functions in your component, which in certain situations might make things more readable and allow you to separate these effects later on to custom hooks more easily.

The useEffect runs by default on every single render, unless you pass dependencies to it. If an effect has dependencies it only runs when they change. By passing an empty array there are no dependencies that can change, so the effect only runs on componentDidMount.

You can also provide an effect with a function return statement. This return statement is ran when the component using the hook is unmounted. If you have subscriptions or need to remove event listeners etc, this is where you have to do that. Always remember to return a function that cleans up possible subscriptions and removes event listeners to prevent memory leaks!

Here are examples showing the situations when useEffect runs depending on the dependencies passed to it;

// Every render
useEffect(() => {
  /* .. code .. */
})

// Runs on 'componentDidMount' 
useEffect(() => {
  /* .. code .. */
}, []) 

// When 'content' changes
useEffect(() => {
  /* .. code .. */
}, [content])

// When 'content' changes
useEffect(() => {
  /* .. code .. */
  return () => {
    // 'componentWillUnmount' code here!
  }
}, [content])

A common source of bugs when using useEffect is to use external functions or props from outside of the effect itself. This is not considered safe and could lead to bugs. You should always be passing all of the props that the effect needs in the dependencies. And you should always be calling functions with the props the effect itself has.

Below are some examples. All of them work but especially the first one could cause bugs in certain situations.

// Bad
const NameComponent = ({ firstName }) => {
  const nameFormatter = () => {
    console.log(firstName)
  }
  useEffect(() => {
    nameFormatter()
  }, [])
}

// OK 
const NameComponent = ({ firstName }) => {
  const nameFormatter = (name) => {
    console.log(name)
  }
  useEffect(() => {
    nameFormatter(firstName)
  }, [firstName])
}

// Better
const NameComponent = ({ firstName }) => {
  useEffect(() => {
    const nameFormatter = (name) => {
      console.log(name)
    }
    nameFormatter(firstName)
  }, [firstName])
}

useLayoutEffect

The signature for this hook is the same as for useEffect. In practice it means that they behave the same and technically you could replace almost all effects with useLayoutEffect. The key difference is that useLayoutEffect waits for all the DOM mutations before rendering while useEffect doesn’t. Not waiting for DOM mutations is usually the preferred way, so unless you deliberately need to block the browsers paint you should be using useEffect. In the rare cases when you need to do some visual calculations or DOM mutation before the browser paints, you can use useLayoutEffect. This way we prevent the layout from flickering suddenly or changing in some way after the initial render.

useContext

The useContext hook does what the name implies. React offers a thing called the Context API, which is a way to pass down props throughout your application. Contexts are helpful when you run into things like excessive prop drilling, which can cause some problems when a codebase grows in size and changes a lot. The useContext hook makes it easier to consume that context. This becomes especially helpful if you have nested or multiple contexts.

// Before
import React, { createContext } from 'react'

const Authorized = createContext({
  isAuthed: true,
  userType: 'admin'
})

export default function App() {
  return (
    <Authorized.Consumer>
      {props => props.isAuthed
        ? `Authorized as ${props.userType}` 
        : 'Unauthorized'
      }
    </Authorized.Consumer>
  )
}

// After
import React, { createContext, useContext } from 'react'

const Authorized = createContext({
  isAuthed: true,
  userType: 'admin'
})

export default function App() {
  const AuthorizedContext = useContext(Authorized)
  return (
    AuthorizedContext.isAuthed 
      ? `Authorized as ${AuthorizedContext.userType}`
      : 'Unauthorized'
  )
}

Note that you need to reference the actual context itself, even though you can only read the context when using the useContext hook.

useReducer

If you need to handle more complex states or find yourself having a bunch of different state calls all over a component you should use useReducer. You get a cleaner and more readable way to set your state. All through the same dispatch function rather than separate update functions.

Let’s say we have a large form with lots of states and complexity. It can often start to look like this;

// Before
const [username, setUsername] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [userTypes, setUserTypes] = useState()
// even more state

When our component is littered with different setters and variables, it becomes harder to read and see what the actual state is in which situation. This is where useReducer comes in.

// After, with useReducer
const [state, dispatch] = useReducer(createUserReducer, initialState)
const {username, email, password, error, userTypes, /* even more state */ } = state

We moved our logic out of the component to createUserReducer where we can only focus on the logic. This way, updating the state is always done through the same dispatch function which returns the new state that is then passed to the component state. The reducer-dispatch flow is very similar to Redux, if you’re familiar with that. There are even arguments for that if your state management doesn’t need all the Redux features, you could get away with using just useState, useReducer and useContext in a smaller applications.

useCallback & useMemo

Why am I grouping these two together? Because they are very much alike, with only a minor difference.

These are hooks you should look at when you start optimizing your application. They help you skip expensive operations that occur every render.

  • useCallback returns a memoized callback, meaning that it only changes when one of its dependencies has changed. 
  • useMemo returns a memoized value, meaning that it only changes when one of its dependencies has changed. 

That means that these are the same;

useCallback(fn, deps)

useMemo(() => fn, deps)

If the dependencies haven’t changed these hooks just return the previous value or function. 

// Function reference will not change unless 'cities' or 'countries' changes

const cb = useCallback(() => {
  return superExpensiveThing(cities, countries)
}, [cities, countries])

console.log(`callback: ${cb()}`)

// Value reference will not change unless 'cities' or 'countries' changes

const memo = useMemo(() => {
  return superExpensiveThing(cities, countries)
}, [cities, countries])

console.log(`memo: ${memo}`)

useRef

Refs are usually used for managing element focus or for example selecting something. You can access the node which the ref is referring to with .current. But since we are now using functions we can’t use the traditional React createRef, since in function components it will be reset on every render.

That is why we have a useRef hook.

// create ref
const ourInput = useRef()

// reference it
<input type={'text'} ref={ourInput} />

// focus input using the ref
focusOurInput = () => textInput.current.focus()

But useRef isn’t only useful for DOM refs, you can actually use it like an instance variable. The refs .current is basically an instance variable, which will persist for the whole lifetime of the component where it is used.

Just like createRef, useRef provides a mutable value in its .current property which will persist for the full lifetime of the component using it. Mutating the .current property will not re-render the component using it.

useRef should always be used carefully and you should check if there are alternatives before using it.

An example where useRef could come in handy is when you do not want an effect to run on the initial render (there are other ways to solve this too);

const isFirstUpdate = useRef(true)

useEffect(() => {
  if (isFirstUpdate.current) {
    isFirstUpdate.current = false
    return
  }
}, [])

Custom Hook

As a final example let’s write a simple demo hook to use event listeners called useEventListener. The function will accept three parameters: the event name, a handler function, and the element (window by default) to add the listener to. 

We start with a useEffect where we add our event listener to your element. Since we don’t want memory leaks, we remember to remove the event listeners in the return function. We also remember that we need to add our dependencies, making the hook run whenever the dependencies change.

export const useEventListener = (eventName, handlerFn, element = window) => {
  useEffect(() => {
    element.addEventListener(eventName, handlerFn)
    return () => element.removeEventListener(eventName, handlerFn)
  }, [eventName, handlerFn, element])
}

Let’s put this hook to use by making a window resize handler which tells us the current window aspect ratio. First, we need a helper function to get the window aspect ratio;

const getAspectRatio = () => {
  const { innerWidth: w, innerHeight: h } = window
  const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b))
  return { width: `${w / gcd(w, h)}`, height: `${h / gcd(w, h)}` }
}

Next, we set the initial state in the component using our custom hook by passing it the function we just created. It will only be ran on the initial render. Now we can create a handler which we’ll pass to the hook. It also uses the setWindowSize function and passes it the helper function we created.

const [windowSize, setWindowSize] = useState(() => getAspectRatio())

const resizeHandler = resizeEvent => {
  setWindowSize(getAspectRatio())
}

useEventListener('resize', resizeHandler, window)

Now we can see the windowSize updating and the height and width properties update whenever we resize the window. They are just regular variables we can use.

<h1>{`ratio: ${windowSize.width} : ${windowSize.height}`}</h1>

We could easily add another event listener in our components now. Let’s add another handler using our hook that tracks the position of our cursor;

const [coordinates, setCoordinates] = useState({ x: 0, y: 0 })

const mousePositionHandler = mouseEvent => {
  setCoordinates({ x: mouseEvent.x, y: mouseEvent.y })
}

useEventListener('mousemove', mousePositionHandler, window)

Then just render the state like before;

<h1>{`mouse: ${coordinates.x}, ${coordinates.y}`}</h1>

Our hook is not perfect, it’s not tested or covers all the edge cases but I think it serves as a good example.

And that’s a wrap.

People experienced with Hooks API might’ve noticed I left out a few of the built-in hooks. If you find yourself using these hooks, you’re probably already pretty comfortable with hooks or have ran into a pretty specific situation. For anyone else interested in these hooks I didn’t mention, you can see how and when to use them in the documentation for useImperativeHandle and useDebugValue.

Vastaa

Sähköpostiosoitettasi ei julkaista. Pakolliset kentät on merkitty *