Mastodon hachyterm.io

Here are some notes from the Epic React workshop Advanced Hooks.

Function Dependencies in useEffect

We use the useEffect hook to run side effects in React. The hook has a “dependency array” which tells it when to fire.

That works fine if the trigger is a variable. Like this:

const [count, setCount] = useState(0)

React.useEffect(() => {
  // Update the document title using the browser API
  document.title = `You clicked ${count} times`
}, [count]) // <-- that's the dependency list

What happens if we use a function as a trigger?

const updateLocalStorage = () => window.localStorage.setItem('count', count)

React.useEffect(() => {
  updateLocalStorage()
}, []) // <-- what goes in that dependency list?

Kent explains that if we put count into the dependency array (the variable from the updateLocalStorage function), we have a disconnect between the update function (updateLocalStorage) and useEffect. If we change updateLocalStorage (the trigger), we have to remember to update the dependency list, too. That’s not ideal.

It also doesn’t make sense to put the updateLocalStorage function as a dependency into the array:

const updateLocalStorage = () => window.localStorage.setItem('count', count)
React.useEffect(() => {
  updateLocalStorage()
}, [updateLocalStorage]) // <-- function as a dependency

The function is re-initialized with every render. Now useEffect runs on every render.

And that’s why we need the useCallback hook:

function asyncReducer(state, action) {
  switch (action.type) {
    case 'pending': {
      return { status: 'pending', data: null, error: null }
    }
    case 'resolved': {
      return { status: 'resolved', data: action.data, error: null }
    }
    case 'rejected': {
      return { status: 'rejected', data: null, error: action.error }
    }
    default: {
      throw new Error(`Unhandled action type: ${action.type}`)
    }
  }
}

function useAsync(asyncCallback, initialState) {
  const [state, dispatch] = React.useReducer(asyncReducer, {
    status: 'idle',
    data: null,
    error: null,
    ...initialState,
  })
  React.useEffect(() => {
    const promise = asyncCallback()
    if (!promise) {
      return
    }
    dispatch({ type: 'pending' })
    promise.then(
      (data) => {
        dispatch({ type: 'resolved', data })
      },
      (error) => {
        dispatch({ type: 'rejected', error })
      }
    )
  }, [asyncCallback])
  return state
}

In your component, you can use the hook like this:

function PokemonInfo({ pokemonName }) {
  const asyncCallback = React.useCallback(() => {
    if (!pokemonName) {
      return
    }
    return fetchPokemon(pokemonName)
  }, [pokemonName])

  const state = useAsync(asyncCallback, {
    status: pokemonName ? 'pending' : 'idle',
  })
  const { data: pokemon, status, error } = state

  if (status === 'idle') {
    return 'Submit a pokemon'
  } else if (status === 'pending') {
    return <PokemonInfoFallback name={pokemonName} />
  } else if (status === 'rejected') {
    throw error
  } else if (status === 'resolved') {
    return <PokemonDataView pokemon={pokemon} />
  }

  throw new Error('This should be impossible')
}

Create a Custom Consumer Hook for React Context

You can create a custom consumer hook for your context:

const CountContext = React.createContext()

function CountProvider(props) {
  const [count, setCount] = React.useState(0)
  const value = [count, setCount]
  return <CountContext.Provider value={value} {...props} />
}

// custom consumer hook!

function useCount() {
  const context = React.useContext(CountContext)
  if (!context) {
    throw new Error('useCount must be used within a CountProvider')
  }
  return context
}

function CountDisplay() {
  const [count] = useCount()
  return <div>{`The current count is ${count}`}</div>
}

function Counter() {
  const [, setCount] = useCount()
  const increment = () => setCount((c) => c + 1)
  return <button onClick={increment}>Increment count</button>
}

function App() {
  return (
    <div>
      <CountProvider>
        <CountDisplay />
        <Counter />
      </CountProvider>
    </div>
  )
}

Scroll Behavior With useImperativeHandle

You can use useImperativeHandle for a “scrollToTop” function:

const MessagesDisplay = React.forwardRef(function MessagesDisplay(
  {messages},
  ref,
) {
  const containerRef = React.useRef()
  React.useLayoutEffect(() => {
    scrollToBottom()
  })
  function scrollToTop() {
    containerRef.current.scrollTop = 0
  }
  function scrollToBottom() {
    containerRef.current.scrollTop = containerRef.current.scrollHeight
  }
  React.useImperativeHandle(ref, () => ({
    scrollToTop,
    scrollToBottom,
  }))

  return (
    <div ref={containerRef} role="log">
    // jsx
    </div>
    )

Complete code is on GitHub.

useDebugValue

The useDebugValue hook which you can use in custom hooks for debugging. You can use it with the React DevTools.

Thoughts

I like how the workshop delves deeper into intermediate patterns and good practices for using hooks. I was familiar with most hooks, but my code wasn’t as re-usable and decoupled as what I learned in the workshop.

Further Reading