Hi, my name is Doug. I’ve been a developer for several years and now work as the Lead Engineer at Shamaazi. Over this period of time, I have written a lot of different UIs and learned a lot of ways to structure React code.
This week I wanted to share my experience of the custom React Hooks I have found the most useful for producing websites in the cleanest, simplest way possible.
React Hooks
Hooks were first introduced to React in version 16.8, after being teased in 2018. There’s a fantastic guide introducing them on the React website. Simply stated, they are a way to write side-effects for functional UI components. This allows you to write parts of your UI as JavaScript functions, but still have the ability to manage state, call APIs, use storage, authenticate users, and so on.
React provides some hooks out of the box (useState
, useEffect
and useContext
being the main three). On top of this, it allows you to compose your own higher-level hooks to separate out reusable logic. These custom hooks are what I’ll explore here. Here are the three I’ve found the most useful across the range of products we produce at Shamaazi.
Performing Asynchronous Actions
Most websites have to perform some form of asynchronous actions, whether it is loading data to display on the page or submitting data based on a user’s input and actions. It’s helpful to keep a track of the status of these asynchronous actions; is it currently loading? has it returned a result? Was there an error?
We found a lot of our components started sharing a lot of similar code, either for fetching data on an initial load or for submitting data. This looked like the following:
const MyComponent = () => {
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
const [result, setResult] = useState(null)
useEffect(() => {
const loadData = async () => {
setResult(null)
setError(null)
setLoading(true)
try {
const result = await doSomeAction();
setResult(result)
} catch (e) {
setError(e)
} finally {
setLoading(false)
}
loadData()
}, [])
if (loading) {
return <>loading...</>
}
if (error) {
return <>something broke</>
}
return <>{result}</>
}
All this loading and error logic can be pulled into a hook, making our interface much neater.
const MyTidyComponent = () => {
const {loading, result, error} = useAsync(doSomeAction)
if (loading) {
return <>loading...</>
}
if (error) {
return <>something broke</>
}
return <>{result}</>
}
This useAsync
hook is responsible for managing the loading, error and result states, removing the need for all this logic within the actual component. It also lets us reuse this throughout our application. This massively simplifies loading data onto a page.
As a bonus, we found we also wanted the ability to execute an action later, rather than just when the component is created. This is useful for performing asynchronous actions based on a user's input; actions like submitting a form can use the same hook but pass a false
value as a second parameter. This indicates that they don’t want the action to be executed straight away.
const { execute, loading, result, error } = useAsync(submitSomeForm, false)
<form onSubmit={execute}>
...
</form>
We also found that the hook sometimes caused a memory leak if a form submission navigated away from the component (e.g. a form might take you to the next page when it is submitted, but setting loading
to false
after you’ve been taken away from the form is a memory leak). We’ve handled this by tracking whether the hook is mounted on the page (tracked through useRef
). We’ll only update any state if the component is still present. This avoids any memory leaks.
The full version of our useAsync
hook is here:
import { useEffect, useState, useCallback, useRef } from 'react'
export default (asyncFunction, immediate = true) => {
const [loading, setLoading] = useState(false)
const [result, setResult] = useState(null)
const [error, setError] = useState(null)
// Track a reference to whether the useAsync is actually on a mounted component.
// useEffect below returns a cleanup that sets this to false. Before setting
// any state, we check if the cleanup has run. If it has, don't update the state.
const mounted = useRef(true)
useEffect(() => {
return () => {
mounted.current = false
}
}, [])
const execute = useCallback(async (...args) => {
setLoading(true)
setResult(null)
setError(null)
try {
const r = await asyncFunction(...args)
if (mounted.current) {
setResult(r)
}
return r
} catch (e) {
if (mounted.current) {
setError(e)
}
} finally {
if (mounted.current) {
setLoading(false)
}
}
}, [asyncFunction])
useEffect(() => {
if (immediate) {
execute()
}
}, [execute, immediate])
return { execute, loading, result, error }
}
Updating LocalStorage or SessionStorage
As part of some of our products, we populate a 'shopping basket'. This keeps a track of what a user has been doing. Sometimes, we want this to persist even if they navigate away from our site, refresh the page, or close the browser. To achieve this, we use a combination of localStorage and sessionStorage
React itself doesn't provide any hooks for storing data in localStorage
or sessionStorage
, but we wanted a consistent experience with useState
. Realistically, it shouldn't be any harder to use localStorage
than it would be to use state normally.
For example, we might want to use localStorage
to keep track of a user's input.
const storageComponent = () => {
const [value, setValue] = useLocalStorage('storage_key', 'default_value')
return <input value={value} onChange={e => setValue(e.target.value}/>
}
Our hooks to achieve this look like the following:
const useStorage = (key, initialValue, storage) => {
// Pass initial state function to useState so logic is only executed once
const [storedValue, setStoredValue] = useState(() => {
try {
const item = storage.getItem(key)
return item ? JSON.parse(item) : initialValue
} catch (error) {
console.error(error)
return initialValue
}
})
useEffect(() => {
try {
// Update storage every time the value is changed
storage.setItem(key, JSON.stringify(storedValue))
} catch (e) {
console.error(e)
}
}, [storedValue, storage, key])
return [storedValue, setStoredValue]
}
export const useLocalStorage = (key, initialValue) => {
return useStorage(key, initialValue, window.localStorage)
}
export const useSessionStorage = (key, initialValue) => {
return useStorage(key, initialValue, window.sessionStorage)
}
Authenticating users
A super common scenario we've come across is having a bunch of components that all care whether a user is logged in. They often care about acting on the user too, through methods like login
, logout
or resetPassword
.
In order to keep all these components in sync, we only want a single source of information about the current user. We could do this by having a component wrapping our entire application that manages a user
state, and passes any props down to where they are used for the user
, login
, logout
or resetPassword
methods.
This quickly becomes messy though, with many components that don't really care being passed user
login
and logout
props even if they don't use them themselves - only a child of theirs does.
Luckily React provides the idea of a context. Allowing us to solve this problem.
We can create an Auth context, and use a hook to get any information from it we want. We can also embed our auth API calls into this context.
This would look like the following to use:
// In our top level App.js
import { ProvideAuth } from 'hooks/useAuth'
export default () => {
return <ProvideAuth>
<RestOfApplication/>
...
</ProvideAuth>
}
// in a component that wants to use Auth
import useAuth from 'hooks/useAuth'
export default () => {
const { user, login, logout, resetPassword } = useAuth();
return <>
{user}
</>
}
This hook itself looks like the following:
import React, { useCallback, useState, useEffect, useContext, createContext } from 'react'
const authContext = createContext()
// Hook for child components to get the auth object and re-render when it changes.
export default () => {
return useContext(authContext)
}
// Provider component that wraps components and makes useAuth() available
export function ProvideAuth({ children }) {
const auth = useAuthProvider()
return <authContext.Provider value={auth}>{children}</authContext.Provider>
}
// Provide Auth hook that creates auth object and handles state
function useAuthProvider() {
const [user, setUser] = useState(null)
// Get the logged in user when created
useEffect(() => {
const user = getLoggedInUser()
setUser(user)
}, [])
const login = async (...) => {
const user = ...
setUser(user)
}
const logout = async () => {
...
setUser(null)
}
const resetPassword = async () => {
...
}
return {
resetPassword
login,
logout,
user
}
}
This has the additional benefit of keeping all of the authentication logic together. To change to a different auth provider, we would only have to change this one file.
Conclusion
React provides some really powerful abstractions for creating code that is neatly organised and easy to read. Here, we’ve looked at the three React Hooks I’ve found the most useful: useAsync
for executing asynchronous actions either when a component is created or when a user performs an action, useStorage
for using localStorage
and sessionStorage
in the same way as useState
, and finally, useAuth
for managing users and authentication.
These three hooks provide powerful abstractions that let you build React components in a simple manner.
Do you have any other custom React Hooks you find useful? Think I’ve missed any key ones? Please let me know.
Looking for other ways to keep your code organised? Check out my article on writing IMMUTABLE code.
Enjoyed this post? Want to share your thoughts on the matter? Found this article helpful? Disagree with me? Let me know by messaging me on Twitter.
Top comments (4)
Please note that these comments are written with a good intention, to help you not to bring you down.
I really encourage you to watch this video about
react-query
: youtube.com/watch?v=seU46c6Jz7EIt convinced me that we should switch from our own custom hook to
react-query
for these types of things.Some feedback on your own implementation:
When you define an interface for your hooks, it helps to name boolean arguments:
Your version of
mounted
could cause some confusion in the future. It is initialized totrue
, but at that point the component is not yet mounted. I would suggest this:Your implementation can cause problems when used in a server-side rendering scenario. It will always log an error because
window.localStorage
will be undefined.Another tricky problem can occur when two different components use it with the same key. They will get out-of-sync.
The above things might not be problematic in your situation, but I think its good to be aware of.
One of the most common problems with using context is that people do not provide a stable
value
. In your version this is also the case. TheuseAuthProvider
creates an object which is passed directly to thevalue
property of the provider.The result is that for every render of
ProvideAuth
all users of the context will re-render, even if nothing actually changed. The solution for this would be to useReact.memo
to create the resulting object.I hope you value this feedback. If you need help fixing these problems or finding solution let me know.
Hey, thanks for the feedback. Some super-useful looking resources there, and some gotchas I definitely hadn't thought about.
<3
Thanks for sharing.
I have some comments
1) you have forgotten to await in your function in the first snippet so it should have been like
2) I think calling directly an async function in useEffect is not recommended. I remember getting an warning the last time I did it.
You're absolutely right on both fronts. I'll update the example. Thanks!