One of the main reasons I, and many others, love React is that it allows us to organize markup into reusable pieces.
Custom React hooks allow us to do the same thing with application state.
I think the name custom hooks can make them seem more complicated than they actually are. A custom hook is just a function that happens to call some special functions in the React library.
Because they are just functions, they can do all the things functions can do. They are reusable, and they can help you maintain separation of concerns in your application, resulting in clean, maintainable, easy-to-read code.
Let's look at an example.
An Example
React applications typically need to do some asynchronous tasks. Say we need to generate a PDF and render it in an iframe
. The process of generating a PDF can take a few seconds, so we'll probably want to start the process, then show some loading indicator while it's running, then display either the PDF or an error message once it's finished. A first attempt might look something like this:
const generatePDF = (contents) => {
// Generate PDF
...
// Returns a promise
}
const PDF = ({ pdfContents }) => {
const [{ status, data: pdf, error }, setState] = React.useReducer(
(prevState, newState) => ({ ...prevState, ...newState }),
{ status: 'idle', data: null, error: null }
)
React.useEffect(() => {
setState({ status: 'pending' })
generatePDF(pdfContents).then(
(data) => setState({ data, status: 'resolved' }),
(error) => setState({ error, status: 'rejected' })
)
}, [pdfContents])
if (status === 'pending') {
return <Spinner />
}
if (status === 'rejected') {
return <Error message={error} />
}
return <iframe title="PDF" src={pdf} />
}
A React component's primary responsibility is to return some markup for React to render, but in this example, we have to scroll past over half of the function body before we get to that point. It feels like the component is doing too much. It's also not immediately clear what the calls to useReducer
and useEffect
are for.
When a function gets too long and confusing, a good thing to do is to split it up into several shorter, more focused functions. We will likely have more asynchronous tasks to perform in other components, so let's first extract the logic for handling loading, error, and success states to its own function. (The following was inspired by this.)
import React from 'react'
const useAsync = () => {
const [{ status, data, error }, setState] = React.useReducer(
(prevState, newState) => ({ ...prevState, ...newState }),
{ status: 'idle', data: null, error: null }
)
const run = React.useCallback((promise) => {
if (!promise || !promise.then) {
throw new Error(
`The argument passed to useAsync().run must be a promise.`
)
}
setState({ status: 'pending' })
return promise.then(
(data) => setState({ data, status: 'resolved' })
(error) => setState({ error, status: 'rejected' })
)
}, [])
return {
isIdle: status === 'idle',
isLoading: status === 'pending',
isError: status === 'rejected',
isSuccess: status === 'resolved',
run,
data,
error,
}
}
This is a custom hook. Again, I want to point out that it is just a function. It just happens to be called a custom hook in React land because 1) its name starts with use
and 2) it calls functions in the React library whose names start with use
.
Now we can change the PDF component to this:
const generatePDF = (contents) => {
// Generate PDF
...
// Returns a promise
}
const PDF = ({ pdfContents }) => {
const { data: pdf, isLoading, error, isError, run } = useAsync()
React.useEffect(() => {
run(generatePDF(pdfContents))
}, [run, pdfContents])
if (isLoading) {
return <Spinner />
}
if (isError) {
return <Error message={error} />
}
return <iframe title="PDF" src={pdf} />
}
This is a lot better, but it still kind of feels like the component is doing too much. Let's extract the useAsync
and useEffect
calls to another function.
const generatePDF = (contents) => {
// Generate PDF
...
// Returns a promise
}
const usePDF = (pdfContents) => {
const { data: pdf, isLoading, error, isError, run } = useAsync()
React.useEffect(() => {
run(generatePDF(pdfContents))
}, [run, pdfContents])
return { pdf, isLoading, isError, error }
}
const PDF = ({ pdfContents }) => {
const { pdf, isLoading, isError, error } = usePDF(pdfContents)
if (isLoading) {
return <Spinner />
}
if (isError) {
return <Error message={error} />
}
return <iframe title="PDF" src={pdf} />
}
The PDF
component looks so much better. All the work of generating the PDF and handling the loading, error, and success states has been reduced to one line, so the component can focus on rendering markup.
It's now very clear what the PDF
component does: it generates a PDF with the provided props, and returns either a Spinner
, Error
, or the pdf in an iframe
. No more trying to decipher the ambiguous calls to useReducer
and useEffect
.
This is nothing new
If you ignore the fact that we're working in a React application, the previous example should feel very familiar to you. Again, all we're doing is taking one big function and splitting it up into smaller functions that each have a single responsibility.
There's nothing new here, which is what makes custom hooks so powerful. It's just one function (the component) calling another function (usePDF
) calling more functions (useAsync
and useEffect
). React only requires that you follow two rules when calling custom hooks, but besides that, all of your intuition about functions can immediately be applied.
Better Dev Tools
Besides just making your code a lot more maintainable, custom hooks make your application easier to debug by improving what you see in the react dev tools.
Let's take a simple example. Say you were building a user registration form. How would you hold the form state? I see a lot of code that looks like this:
import React from 'react'
const RegisterForm = ({ onSubmit }) => {
const [username, setUsername] = React.useState('')
const [firstName, setFirstName] = React.useState('')
const [lastName, setLastName] = React.useState('')
const [email, setEmail] = React.useState('')
const [password, setPassword] = React.useState('')
const [confirmPassword, setConfirmPassword] = React.useState('')
return (
<form>
<input
value={username}
onChange={(e) => setUsername(e.target.value)}
/>
...
</form>
)
}
This works fine, but when you open up the React dev tools in your browser, you'll see this:
This isn't very helpful. It's not clear at all that these pieces of state belong to the form.
To make this a bit clearer, we can extract all these useState
calls to another function. Better yet, we can also replace all the useState
calls with one useReducer
call.
import React from 'react'
const useRegisterForm = () => {
return React.useReducer(
(prevState, newState) => ({ ...prevState, ...newState }),
{
username: '',
password: '',
confirmPassword: '',
firstName: '',
lastName: '',
email: '',
}
)
}
const RegisterForm = ({ onSubmit }) => {
const [registerForm, setRegisterForm] = useRegisterForm()
return (
<form>
<input
value={registerForm.username}
onChange={(e) => setRegisterForm({ username: e.target.value })}
/>
...
</form>
)
}
Now the dev tools are much clearer:
Notice that all of the state in the useRegisterForm
hook is shown under RegisterForm
. This will happen with every custom hook; a hook named useCustomHook
will show up as CustomHook
in the dev tools.
How much?
Custom hooks are awesome, but how often should you extract your state to custom hooks?
Honestly, I think you should move state to custom hooks more often than not. As we've discussed, they allow you keep related pieces of state together which improves the readability of your components. And with the added benefits of being reusable and improved dev tools, it's hard to justify not using them all the time.
Conclusion
It took me a while to figure out how helpful custom hooks are, but once I did, I never looked back. I use them all the time now and my code is much better for it. If you haven't been using custom hooks in your applications, I highly recommend you start.
Top comments (11)
Thank you. Requires a second read but totally worth it
Glad you liked it!
Thank for this article! I've been working on a form component for some time and have been blindly adding complexity. And now that I'm trying to test and add more functionality it breaks down. Back to the basics, make it simple!
helpful
I have a question, why did you use the useReducer instead of create a useState like const [registerForm, setRegisterForm] = useState({...})?
Great question. You could create the form like you said:
but now the code to update the form needs to change. For example, to update the username, you'd have to write
and to update the password,
etc. You have to repeat the
(prevState) => ({ ...prevState,
part every time, which I'd rather not do. UsinguseReducer
allows me to define the update function once withand just pass the new state to
setRegisterForm
.ohhhh make sense, thanks for the reply !
Great article!
good job man <3
which lib do you use to generate PDFs?
The example was meant to be hypothetical, but I've heard jspdf is a good library.