That’s it. That’s the entire idea behind custom hooks. So…
function useCount() {const [count, setCount] = useState(0)const increment = () => setCount((c) => c + 1)return { count, increment }}function Counter() {const { count, increment } = useCount()return <button onClick={increment}>{count}</button>}
The useCount
function is a custom hook. The use
prefix is a convention to
indicate that this function is a hook and has to follow all of the
rules of hooks
Custom hooks are fantastic because they allow you to encapsulate complex logic and share that logic between components.
However, all abstraction has a cost, and that’s true of custom hooks as well.
In particular, if you have a utility function you want to share and people call
that function within a useEffect
or other hook which has a dependency array
you suddenly need to start worrying about identity and referential equality.
For example, consider our increment
function from the useCount
hook. Let’s
pretend we’re going to use it in a useEffect
:
function Counter() {const { count, increment } = useCount()React.useEffect(() => {// set up a timer to increment the count every second or something...const id = setInterval(() => {increment()}, 1000)return () => clearInterval(id)}, [increment]) // <-- we need to include increment in the dependency listreturn <div>{count}</div>}
The problem with this is that increment
is a new function every render as we
currently have it defined. So every re-render of Counter
will cause the
useEffect
cleanup to be run clearing the interval and setting up a new one.
This is not what we want. So we have to make sure that the increment
function
never changes. We can do that with the useCallback
hook.
Before we get into useCallback
, let’s talk about memoization in general.
Memoization: a performance optimization technique which eliminates the need to recompute a value for a given input by storing the original computation and returning that stored value when the same input is provided. Memoization is a form of caching. Here’s a simple implementation of memoization:
const values = {}function addOne(num: number) {if (values[num] === undefined) {values[num] = num + 1 // <-- here's the computation}return values[num]}
One other aspect of memoization is value referential equality. For example:
const dog1 = new Dog('sam')const dog2 = new Dog('sam')console.log(dog1 === dog2) // false
Even though those two dogs have the same name, they are not the same. However, we can use memoization to get the same dog:
const dogs = {}function getDog(name: string) {if (dogs[name] === undefined) {dogs[name] = new Dog(name)}return dogs[name]}const dog1 = getDog('sam')const dog2 = getDog('sam')console.log(dog1 === dog2) // true
You might have noticed that our memoization examples look very similar. Memoization is something you can implement as a generic abstraction:
function memoize<ArgType, ReturnValue>(cb: (arg: ArgType) => ReturnValue) {const cache: Record<ArgType, ReturnValue> = {}return function memoized(arg: ArgType) {if (cache[arg] === undefined) {cache[arg] = cb(arg)}return cache[arg]}}const addOne = memoize((num: number) => num + 1)const getDog = memoize((name: string) => new Dog(name))
Our abstraction only supports one argument, if you want to make it work for any type/number of arguments, knock yourself out.
Luckily, in React we don’t have to implement a memoization abstraction. They
made two for us! useMemo
and useCallback
. For more on this read:
Memoization and React.
So, going back to our earlier example, here’s how you solve the problem:
function useCount() {const [count, setCount] = useState(0)const increment = useCallback(() => setCount((c) => c + 1), [])return { count, increment }}function Counter() {const { count, increment } = useCount()useEffect(() => {const id = setInterval(increment, 1000)return () => clearInterval(id)}, [increment])return <div>{count}</div>}
What that does is we pass React a function and React gives that same function back to us… Sounds kinda useless right? Imagine:
// this is not how React actually implements this function. We're just imagining!function useCallback(callback) {return callback}
Uhhh… But there’s a catch! On subsequent renders, if the elements in the dependency list are unchanged, instead of giving the same function back that we give to it, React will give us the same function it gave us last time. So imagine:
// this is not how React actually implements this function. We're just imagining!let lastCallbackfunction useCallback(callback, deps) {if (depsChanged(deps)) {lastCallback = callbackreturn callback} else {return lastCallback}}
So while we still create a new function every render (to pass to useCallback
),
React only gives us the new one if the dependency list changes.
In this exercise, we’re going to be using useCallback
, but useCallback
is
just a shortcut to using useMemo
for functions:
// the useMemo version:const increment = useMemo(() => () => setCount((c) => c + 1), [])// the useCallback versionconst increment = useCallback(() => setCount((c) => c + 1), [])
One other thing to note is that useCallback
and useMemo
also have dependency
array and it functions the same way as the dependency array in useEffect
. If
you don’t include all the dependencies, you’ll have bugs. And if the things you
include in the dependency array are not stable, then it’ll undo the stability
of the memoization as well. This is how dependency arrays can spider out into
everything you do which is one reason why it’s handy to avoid abstracting early.
There’s a strategy to help you avoid dependency arrays talked about in
Myths about useEffect
.
Additionally, there’s a pattern you can use to help reduce the issue (with its
own set of trade-offs) which we’ll explore in the React Patterns workshop.
🦉 Better understand memoization by diving deep on caching with my talk Caching for Cash.
🦉 A common question with this is: “Why don’t we just wrap every function in
useCallback
?” You can read about this in my blog post
When to useMemo and useCallback.
🦉 And if the concept of a “closure” is new or confusing to you, then give this a read. (Closures are one of the reasons it’s important to keep dependency lists correct.)
👨💼 We only call the setSearchParams
function inside event handlers, so we
don’t have any problems, but we’re making a reusable hook and we want to make
certain people don’t have problems if they need to use it in a useEffect
or
other hook that requires a dependency array. For example:
const [searchParams, setSearchParams] = useSearchParams()useEffect(() => {if (someCondition) {setSearchParams({ foo: 'bar' })}}, [setSearchParams, someCondition])
So I want you to wrap our setSearchParams
function in useCallback
to memoize
it and avoid issues with the dependency array.
import { useCallback, useEffect, useState } from 'react'import * as ReactDOM from 'react-dom/client'import {type BlogPost,generateGradient,getMatchingPosts,} from '#shared/blog-posts'import { setGlobalSearchParams } from '#shared/utils'const getQueryParam = (params: URLSearchParams) => params.get('query') ?? ''export function useSearchParams() {const [searchParams, setSearchParamsState] = useState(() => new URLSearchParams(window.location.search),)useEffect(() => {function updateSearchParams() {setSearchParamsState((prevParams) => {const newParams = new URLSearchParams(window.location.search)return prevParams.toString() === newParams.toString()? prevParams: newParams})}window.addEventListener('popstate', updateSearchParams)return () => window.removeEventListener('popstate', updateSearchParams)}, [])const setSearchParams = useCallback((...args: Parameters<typeof setGlobalSearchParams>) => {const searchParams = setGlobalSearchParams(...args)setSearchParamsState((prevParams) => {return prevParams.toString() === searchParams.toString()? prevParams: searchParams})return searchParams},[],)return [searchParams, setSearchParams] as const}function App() {const [searchParams, setSearchParams] = useSearchParams()const query = getQueryParam(searchParams)return (<div className="app"><Form query={query} setSearchParams={setSearchParams} /><MatchingPosts query={query} /></div>)}function Form({query,setSearchParams,}: {query: stringsetSearchParams: typeof setGlobalSearchParams}) {const words = query.split(' ').map((w) => w.trim())const dogChecked = words.includes('dog')const catChecked = words.includes('cat')const caterpillarChecked = words.includes('caterpillar')function handleCheck(tag: string, checked: boolean) {const newWords = checked ? [...words, tag] : words.filter((w) => w !== tag)setSearchParams({ query: newWords.filter(Boolean).join(' ').trim() },{ replace: true },)}return (<form onSubmit={(e) => e.preventDefault()}><div><label htmlFor="searchInput">Search:</label><inputid="searchInput"name="query"type="search"value={query}onChange={(e) =>setSearchParams({ query: e.currentTarget.value }, { replace: true })}/></div><div><label><inputtype="checkbox"checked={dogChecked}onChange={(e) => handleCheck('dog', e.currentTarget.checked)}/>{' '}🐶 dog</label><label><inputtype="checkbox"checked={catChecked}onChange={(e) => handleCheck('cat', e.currentTarget.checked)}/>{' '}🐱 cat</label><label><inputtype="checkbox"checked={caterpillarChecked}onChange={(e) =>handleCheck('caterpillar', e.currentTarget.checked)}/>{' '}🐛 caterpillar</label></div></form>)}function MatchingPosts({ query }: { query: string }) {const matchingPosts = getMatchingPosts(query)return (<ul className="post-list">{matchingPosts.map((post) => (<Card key={post.id} post={post} />))}</ul>)}function Card({ post }: { post: BlogPost }) {const [isFavorited, setIsFavorited] = useState(false)return (<li>{isFavorited ? (<buttonaria-label="Remove favorite"onClick={() => setIsFavorited(false)}>❤️</button>) : (<button aria-label="Add favorite" onClick={() => setIsFavorited(true)}>🤍</button>)}<divclassName="post-image"style={{ background: generateGradient(post.id) }}/><ahref={post.id}onClick={(event) => {event.preventDefault()alert(`Great! Let's go to ${post.id}!`)}}><h2>{post.title}</h2><p>{post.description}</p></a></li>)}const rootEl = document.createElement('div')document.body.append(rootEl)ReactDOM.createRoot(rootEl).render(<App />)
html,body {margin: 0;}.app {margin: 40px auto;max-width: 1024px;form {text-align: center;}}.post-list {list-style: none;padding: 0;display: flex;gap: 20px;flex-wrap: wrap;justify-content: center;li {position: relative;border-radius: 0.5rem;overflow: hidden;border: 1px solid #ddd;width: 320px;transition: transform 0.2s ease-in-out;a {text-decoration: none;color: unset;}&:hover,&:has(*:focus),&:has(*:active) {transform: translate(0px, -6px);}.post-image {display: block;width: 100%;height: 200px;}button {position: absolute;font-size: 1.5rem;top: 20px;right: 20px;background: transparent;border: none;outline: none;&:hover,&:focus,&:active {animation: pulse 1.5s infinite;}}a {padding: 10px 10px;display: flex;gap: 8px;flex-direction: column;h2 {margin: 0;font-size: 1.5rem;font-weight: bold;}p {margin: 0;font-size: 1rem;color: #666;}}}}@keyframes pulse {0% {transform: scale(1);}50% {transform: scale(1.3);}100% {transform: scale(1);}}
Quick Links
Legal Stuff
Social Media